diff --git a/.env.example b/.env.example index 9528dc996..6522a9346 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,22 @@ CHATGPT2API_REGISTRATION_ENABLED=false # 检查更新访问 DockerHub / Release API 的代理;未设置时复用 CHATGPT2API_PROXY # CHATGPT2API_UPDATE_PROXY_URL=http://127.0.0.1:7890 +# ============================================ +# 服务器源码 Docker 构建资源控制(仅 sh deploy/docker-build-limited.sh 使用) +# ============================================ + +# 需要在服务器从源码构建镜像时,推荐用脚本创建受限 BuildKit builder 后再构建。 +# 直接运行脚本时会按服务器资源自动选择默认值,资源充足时默认使用 2 核和更高内存。 +# 如需手动覆盖,可取消下面注释。 +# BUILD_CPUS=2 +# BUILD_MEMORY=4g +# BUILD_MEMORY_SWAP=4g +# BUILDKIT_MAX_PARALLELISM=2 +# BUILD_GOMAXPROCS=2 +# BUILD_GOMEMLIMIT=2GiB +# BUILD_NODE_OPTIONS=--max-old-space-size=1024 +# CHATGPT2API_LOCAL_IMAGE=chatgpt2api:local + # 限流账号检查间隔,单位:分钟 CHATGPT2API_REFRESH_ACCOUNT_INTERVAL_MINUTE=5 @@ -33,6 +49,9 @@ CHATGPT2API_USER_DEFAULT_RPM_LIMIT=0 # 服务端缓存图片保留天数 CHATGPT2API_IMAGE_RETENTION_DAYS=30 +# 图片库总容量上限,单位 MB;0 表示不按容量自动清理 +CHATGPT2API_IMAGE_STORAGE_LIMIT_MB=0 + # 业务日志保留天数;设置页的日志数据治理会按该值清理历史日志 CHATGPT2API_LOG_RETENTION_DAYS=7 @@ -74,12 +93,12 @@ CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS=false # 存储后端配置 # ============================================ -# 存储后端类型(可选值: json, sqlite, postgres) +# 存储后端类型(可选值: sqlite, postgres, mysql) # 默认: sqlite STORAGE_BACKEND=sqlite # ============================================ -# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用) +# 数据库配置 # ============================================ # PostgreSQL 示例 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b84aa3e32..6c3286a3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,15 @@ on: push: branches: - main + - dev pull_request: permissions: contents: read +env: + BUN_VERSION: 1.3.13 + jobs: test: runs-on: ubuntu-latest @@ -17,7 +21,10 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ env.BUN_VERSION }} + + - name: Clear Bun cache + run: bun pm cache rm || true - name: Install dependencies working-directory: web @@ -27,6 +34,13 @@ jobs: working-directory: web run: bun run build + - name: Upload frontend artifact + uses: actions/upload-artifact@v7 + with: + name: web-dist + path: internal/web/dist/ + retention-days: 1 + - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -35,5 +49,73 @@ jobs: - name: Verify Go version run: go version | grep -q 'go1.26.2' + - name: Configure git for Go modules + run: git config --global url."https://${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" + - name: Test backend run: go test ./... + + - name: Validate Docker deployment files + run: | + test -f deploy/Dockerfile + test -f deploy/Dockerfile.dev + test -f deploy/Dockerfile.release + test -f deploy/Dockerfile.dockerignore + test -f deploy/Dockerfile.dev.dockerignore + test -f deploy/docker-compose.yml + sh -n deploy/docker-build-limited.sh + test ! -e .dockerignore + test ! -e Dockerfile + test ! -e Dockerfile.goreleaser + test ! -e docker-compose.yml + test ! -e docker-compose.build.yml + test ! -e docker-compose.local.yml + test ! -e .goreleaser.simple.yaml + CHATGPT2API_ENV_FILE="$PWD/.env.example" \ + CHATGPT2API_DATA_DIR="$PWD/data" \ + docker compose -f deploy/docker-compose.yml config >/dev/null + + build-docker-dev: + needs: [test] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - name: Download frontend artifact + uses: actions/download-artifact@v8 + with: + name: web-dist + path: internal/web/dist/ + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + run: | + OWNER_LOWER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]') + echo "tags=ghcr.io/${OWNER_LOWER}/${{ github.event.repository.name }}:dev" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: deploy/Dockerfile.dev + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=dev + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d802f2c4d..17bb7718d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,19 +10,14 @@ on: description: "Tag to release, for example v1.0.0" required: true type: string - simple_release: - description: "Only publish linux/amd64 GHCR image" - required: false - type: boolean - default: false permissions: contents: write packages: write env: - SIMPLE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.simple_release || 'false' }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + BUN_VERSION: 1.3.13 jobs: build-frontend: @@ -45,7 +40,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ env.BUN_VERSION }} + + - name: Clear Bun cache + run: bun pm cache rm || true - name: Install dependencies working-directory: web @@ -87,6 +85,9 @@ jobs: - name: Verify Go version run: go version | grep -q 'go1.26.2' + - name: Configure git for Go modules + run: git config --global url."https://${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -129,7 +130,7 @@ jobs: uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" - args: release --clean --skip=validate ${{ env.SIMPLE_RELEASE == 'true' && '--config=.goreleaser.simple.yaml' || '' }} + args: release --clean --skip=validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_MESSAGE: ${{ steps.meta.outputs.body }} diff --git a/.gitignore b/.gitignore index 279322c7f..eb7024d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,14 @@ dist/ web_dist chatgpt2api.exe .omx/ +.codex/ venv/ .venv/ __pycache__/ +docs/* +!docs/image-generation-api.md +!docs/user-quota-allocation-requirements.md +!jshook/docs/ .idea node_modules/ diff --git a/.goreleaser.simple.yaml b/.goreleaser.simple.yaml deleted file mode 100644 index 89f6c6b49..000000000 --- a/.goreleaser.simple.yaml +++ /dev/null @@ -1,61 +0,0 @@ -version: 2 - -project_name: chatgpt2api - -builds: - - id: chatgpt2api - main: ./cmd/chatgpt2api - binary: chatgpt2api - flags: - - -tags=embed - env: - - CGO_ENABLED=0 - goos: - - linux - goarch: - - amd64 - ldflags: - - -s -w - - -X chatgpt2api/internal/version.Version={{ .Version }} - - -X chatgpt2api/internal/version.Commit={{ .Commit }} - - -X chatgpt2api/internal/version.Date={{ .Date }} - - -X chatgpt2api/internal/version.BuildType=release - -archives: [] - -checksum: - disable: true - -changelog: - disable: true - -dockers: - - id: ghcr-amd64 - goos: linux - goarch: amd64 - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:latest" - dockerfile: Dockerfile.goreleaser - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.version={{ .Version }}" - - "--label=org.opencontainers.image.revision={{ .Commit }}" - - "--label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}" - -docker_manifests: [] - -release: - draft: false - prerelease: auto - name_template: "chatgpt2api {{ .Version }} (Simple)" - skip_upload: true - header: | - {{ .Env.TAG_MESSAGE }} - footer: | - - --- - - Simple release only publishes the linux/amd64 GHCR Docker image. diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e3de5fc1c..0f7b17bd4 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,7 +4,7 @@ project_name: chatgpt2api builds: - id: chatgpt2api - main: ./cmd/chatgpt2api + main: ./internal binary: chatgpt2api flags: - -tags=embed @@ -24,13 +24,13 @@ builds: archives: - id: default - format: tar.gz + formats: ["tar.gz"] name_template: >- {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} files: - README.md - .env.example - - docker-compose.yml + - deploy/docker-compose.yml checksum: name_template: checksums.txt @@ -39,96 +39,43 @@ checksum: changelog: disable: true -dockers: - - id: dockerhub-amd64 - goos: linux - goarch: amd64 - skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' - image_templates: - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-amd64" - dockerfile: Dockerfile.goreleaser - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.version={{ .Version }}" - - "--label=org.opencontainers.image.revision={{ .Commit }}" - - - id: dockerhub-arm64 - goos: linux - goarch: arm64 - skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' - image_templates: - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-arm64" - dockerfile: Dockerfile.goreleaser - use: buildx - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.version={{ .Version }}" - - "--label=org.opencontainers.image.revision={{ .Commit }}" - - - id: ghcr-amd64 - goos: linux - goarch: amd64 - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - dockerfile: Dockerfile.goreleaser - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.version={{ .Version }}" - - "--label=org.opencontainers.image.revision={{ .Commit }}" - - "--label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}" - - - id: ghcr-arm64 - goos: linux - goarch: arm64 - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-arm64" - dockerfile: Dockerfile.goreleaser - use: buildx - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.version={{ .Version }}" - - "--label=org.opencontainers.image.revision={{ .Commit }}" - - "--label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}" - -docker_manifests: - - name_template: "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}" - skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' - image_templates: - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-amd64" - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-arm64" - - - name_template: "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:latest" - skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' - image_templates: - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-amd64" - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-arm64" - - - name_template: "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Major }}.{{ .Minor }}" - skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' - image_templates: - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-amd64" - - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-arm64" - - - name_template: "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-arm64" - - - name_template: "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:latest" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-arm64" - - - name_template: "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Major }}.{{ .Minor }}" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-arm64" +dockers_v2: + - id: dockerhub + disable: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' + dockerfile: deploy/Dockerfile.release + images: + - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api" + tags: + - "{{ .Version }}" + - "latest" + - "{{ .Major }}.{{ .Minor }}" + platforms: + - linux/amd64 + - linux/arm64 + labels: + "org.opencontainers.image.version": "{{ .Version }}" + "org.opencontainers.image.revision": "{{ .FullCommit }}" + + - id: ghcr + dockerfile: deploy/Dockerfile.release + images: + - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api" + tags: + - "{{ .Version }}" + - "latest" + - "{{ .Major }}.{{ .Minor }}" + platforms: + - linux/amd64 + - linux/arm64 + labels: + "org.opencontainers.image.version": "{{ .Version }}" + "org.opencontainers.image.revision": "{{ .FullCommit }}" + "org.opencontainers.image.source": "https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}" release: draft: false prerelease: auto + replace_existing_artifacts: true name_template: "chatgpt2api {{ .Version }}" header: | {{ .Env.TAG_MESSAGE }} diff --git a/AGENTS.md b/AGENTS.md index a2c71b4c1..db3589249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,30 +7,26 @@ Write for the current API version only. Do not add fallbacks, shims, feature fla ## Project Structure & Module Organization -This repository is a Go backend with a Vite/React admin UI. The backend entry point is `cmd/chatgpt2api/main.go`; implementation packages live under `internal/` (`httpapi`, `service`, `protocol`, `backend`, `storage`, `config`, and helpers). Frontend source is in `web/src/`, with pages under `web/src/app/`, shared UI in `web/src/components/`, API helpers in `web/src/lib/`, and stores in `web/src/store/`. Screenshots are in `assets/`. ChatGPT web reverse-engineering notes live in `jshook/docs/`, with validation scripts and sanitized response samples under `jshook/`. +This repository is a Go backend with a Vite/React admin UI. The backend entry point is `internal/main.go`; implementation packages live under `internal/` (`httpapi`, `service`, `protocol`, `backend`, `storage`, `config`, and helpers). Frontend source is in `web/src/`, with pages under `web/src/app/`, shared UI in `web/src/components/`, API helpers in `web/src/lib/`, and stores in `web/src/store/`. Screenshots are in `assets/`. ChatGPT web reverse-engineering notes live in `jshook/docs/`, with validation scripts and sanitized response samples under `jshook/`. ## Build, Test, and Development Commands - `cd web && npm run build` generates the embedded admin UI assets under `internal/web/dist`. - `go test ./...` runs all backend tests after the frontend assets exist. -- `go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./cmd/chatgpt2api` builds the service binary with embedded admin UI assets. +- `go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./internal` builds the service binary with embedded admin UI assets. - `CHATGPT2API_ADMIN_PASSWORD=change_me_please ./chatgpt2api` runs the backend locally after build. -- `docker compose up -d` starts the default containerized deployment using `.env`. -- `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` rebuilds the image from local source. +- `docker compose -f deploy/docker-compose.yml up -d` starts the default containerized deployment using `.env`. +- `sh deploy/docker-build-limited.sh up` rebuilds the image from local source on a server with a resource-capped BuildKit builder. - `cd web && npm run dev` starts the frontend dev server. - `cd web && npm run build` type-checks and builds the frontend. -- `cd web && npm run lint` runs ESLint. +- `cd web && npm run lint` runs Oxlint. ## Coding Style & Naming Conventions -Use `gofmt` for Go code and keep package names short, lowercase, and domain-oriented. Place tests beside the code they exercise as `*_test.go`. Frontend code uses TypeScript, React 19, Vite, ESLint, Tailwind CSS, and shadcn-style components. Prefer kebab-case filenames for React components (`image-composer.tsx`) and PascalCase exports. Reuse helpers from `web/src/lib/` and primitives from `web/src/components/ui/` before adding abstractions. +Use `gofmt` for Go code and keep package names short, lowercase, and domain-oriented. Place tests beside the code they exercise as `*_test.go`. Frontend code uses TypeScript, React 19, Vite, Oxlint, Tailwind CSS, and shadcn-style components. Prefer kebab-case filenames for React components (`image-composer.tsx`) and PascalCase exports. Reuse helpers from `web/src/lib/` and primitives from `web/src/components/ui/` before adding abstractions. Admin async creation-task routes use `/api/creation-tasks` as the resource root. Submit task-type-specific work through explicit child resources: `image-generations`, `image-edits`, and `chat-completions`. Do not introduce image-named task aliases or chat routes under image-named resources. -## Design Guidelines - -For frontend UI and visual design work, consult `DESIGN.md` for the project design system and apply those rules unless the user explicitly requests a different direction. - ## jshook Reverse-Engineering Notes Use `jshook/README.md` as the index for ChatGPT web protocol research. Keep endpoint inventories, content-type mappings, request-flow notes, internal codename mappings, and authenticated API schema notes in `jshook/docs/*.md` rather than duplicating them elsewhere. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..686ccfb2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,197 @@ +# Changelog + +## [0.2.1] - 2026-05-14 + +### 新增 + +- 用户管理列表显示用户 ID,并支持通过表头按 ID、名称、角色、余额、调用量和最近使用时间排序。 + +### 变更 + +- 用户管理列表默认按用户 ID 倒序展示,分页前先在后端完成排序,结果顺序更稳定。 +- 优化用户管理查询路径,减少用量和计费信息加载时的全量计算开销。 +- 移除本地 JSON 文件存储后端,`STORAGE_BACKEND` 仅保留 `sqlite`、`postgres` 和 `mysql`。 + +### 修复 + +- 修复管理端按单个用户 ID 查询时仍重建完整用户列表的问题。 +- 关闭应用时释放数据库连接,避免 Windows 环境下 SQLite 文件被占用。 +- 修复 release workflow 重跑 `v0.2.1` 时同名 GitHub Release 资产上传失败的问题。 + +### 破坏性变更 + +- `STORAGE_BACKEND=json` 不再支持;本地日志、用户和配置数据需要使用数据库后端。 + +## [0.2.0] - 2026-05-13 + +### 新增 + +- 用户配额分配系统:引入本地标准余额(standard-balance)和订阅配额(subscription-quota)两套独立计费模型,按用户维度管理图片消费额度,与上游账号池配额完全隔离。 +- 用户配额管理 API:支持管理员为用户设置、调整余额和配额,同步协议路由、异步创作任务和管理端接口均受配额管控。 +- 原子计费机制:异步图片任务在提交时预扣费,交付时按实际图片输出数量结算,未产出的图片自动退款;已取消或过期的预订自动清理,杜绝未付费图片泄漏。 +- 批量计费调整接口:管理员可按用户 ID 列表或角色批量执行计费调整,返回逐用户结果和汇总统计。 +- 计费默认值仅初始化新用户:更改全局默认计费设置后,已有用户的计费状态不受影响,仅在创建新用户时自动应用当前默认值。 +- 图片生成元数据持久化:生成图片时自动记录 prompt、模型、quality 和参考图片等参数,支持后续复用。 +- 图片公开画廊:用户可将图片发布至公开画廊并共享生成参数,其他用户可一键"生成类似"自动携带 prompt 和参考图片。 +- 图片存储治理仪表盘:管理端新增图片存储治理面板,支持查看存储占用、按保留天数清理和按容量配额自动淘汰。 +- `IMAGE_STORAGE_LIMIT_MB` 配置项:控制图片库总容量上限(单位 MB),超限时在下次图片生成时自动触发清理;设为 0 表示不按容量清理。 +- `IMAGE_RETENTION_DAYS` 配置项:控制服务端缓存图片保留天数,默认 30 天。 +- 用户个人资料页展示当前余额和配额使用情况。 +- 管理端用户列表增加配额分配和消费明细操作入口。 +- 顶部导航栏展示当前用户配额余额。 + +### 变更 + +- 图片交付流程从预订扣费改为原子扣费/退款,消除了异步场景下的竞态条件。 +- 用户管理页增加配额相关列和批量操作,信息展示更完整。 +- 图片创作台提示用户当前配额余额,余额不足时给出明确提示。 +- 移除 `AGENTS.md` 和 `DESIGN.md`。 + +### 修复 + +- 修复管理员浏览公开画廊时只能看到公开图片的问题,现在管理员在公开画廊视图下可查看全部图片。 +- 修复配置解析中 `.env` 文件值被陈旧进程环境变量覆盖的问题,现在 `.env` 文件值优先于进程环境变量。 + +## [0.1.9] - 2026-05-11 + +### 新增 + +- 文本聊天对齐官方 prepare+conduit 流程,提升请求稳定性和兼容性。 +- 多模态视觉支持:聊天中可发送图片进行视觉理解,自动选择支持视觉的账号处理。 +- 注册模块增加 inbucket 和 yyds_mail 邮件提供商,完善注册登录流程。 +- 智能文本账号轮换机制,结合 SSE EOF 截断修复,提升长对话可靠性。 + +### 变更 + +- 图片生成链路中,带图片的聊天请求自动路由至支持视觉的账号,不再强制进入图片生成模式。 +- 前端 UI 中的认证图片资源增加缓存,减少重复请求提升加载速度。 + +### 修复 + +- 修复多模态 SSE 解析时将原始 completion map 输出为文本的问题。 +- 修复 token 过期识别逻辑,正确将过期账号标记为无效并触发刷新。 +- 多模态对话增加 `force_use_sse` 参数,防止流式传输被意外截断。 +- 修复参考图片在聊天和图片两种模式下的显示问题。 +- 修复纯文本图片任务被错误标记为通用失败的问题。 +- 修复图片生成模式下上传图片被强制切换模式的问题。 +- 多模态 SSE 载荷解析支持从 "v" 字段提取文本内容。 +- 修复 Dockerfile 引用路径,CI 构建和本地脚本统一使用 `deploy/Dockerfile.release`。 + +### 文档 + +- 图片生成 API 使用说明优化,提升可发现性。 + +## [0.1.8] - 2026-05-08 + +### 新增 + +- 图片生成支持 Codex Responses 路由,区分官方 `gpt-image-2` 与 Codex 别名模型。 +- 图片输出支持 JPEG 和 WebP 格式,可自定义宽高比和尺寸预设。 +- 图片质量选择功能,支持 standard 和 hd 模式。 +- 图片任务聊天模式,在图片创作中支持对话式交互。 +- 图片并发限制和用户级 RPM 速率控制,防止资源滥用。 +- 图片管理器增加客户端缓存、浮动操作菜单和移动端过滤优化。 +- 图片公开画廊和可见性管理功能。 +- 用户提示词收藏功能和创作者名称持久化。 +- 基于角色的访问控制(RBAC)、密码账号和用户资料管理。 +- LinuxDo OAuth 登录集成和信任等级追踪。 +- 用户管理页面和 API 集成。 +- 公告管理功能。 +- HTTP 审计日志和增强的日志搜索(基于 slog)。 +- 日志治理与自动清理机制。 +- 从 JSON 文件迁移到 SQLite 存储后端。 +- 自更新系统,支持 GoReleaser 发布流水线。 +- 可配置的更新仓库和 GitHub Token。 +- 回复图片生成功能。 +- 导航栏任务队列弹窗和用户用量火花图。 +- 侧边栏 Telegram 和 GitHub 链接。 + +### 变更 + +- 后端运行时从 Python 迁移为纯 Go 实现。 +- Docker 构建改用 bun 管理前端依赖。 +- 缩略图从 WebP 切换为 JPEG 以减小体积。 +- 图片管理器操作改为浮动弹出菜单,改进移动端体验。 +- 设置页卡片布局简化,使用瀑布流列。 +- 登录页面重新设计,支持自定义图片和应用元数据。 +- 调色板更新为蓝色调主题和柔和阴影。 + +### 修复 + +- 修复滚动锁定时页面被额外添加外边距的问题。 +- 修复更新仓库配置的空值检查和 Token 占位符。 +- 修复账号标识处理和移除角色显示。 +- 修复图片管理器中创作者名称的处理。 +- 修复登录页面过渡动画和无障碍访问。 +- 修复缩略图路径遍历漏洞。 +- 修复发布工作流的 secret 条件判断。 + +### 破坏性变更 + +- 移除 Python 运行时依赖,Go 成为唯一后端。 +- API 路由从 `image-tasks` 更名为 `creation-tasks`。 + +## [0.1.7] - 2026-05-08 + +### 新增 + +- 文本聊天对齐官方 prepare+conduit 流程,提升请求稳定性和兼容性。 +- 多模态视觉支持:聊天中可发送图片进行视觉理解,自动选择支持视觉的账号处理。 +- 注册模块增加 inbucket 和 yyds_mail 邮件提供商,完善注册登录流程。 +- 智能文本账号轮换机制,结合 SSE EOF 截断修复,提升长对话可靠性。 + +### 变更 + +- 图片生成链路中,带图片的聊天请求自动路由至支持视觉的账号,不再强制进入图片生成模式。 +- 前端 UI 中的认证图片资源增加缓存,减少重复请求提升加载速度。 + +### 修复 + +- 修复多模态 SSE 解析时将原始 completion map 输出为文本的问题。 +- 修复 token 过期识别逻辑,正确将过期账号标记为无效并触发刷新。 +- 多模态对话增加 `force_use_sse` 参数,防止流式传输被意外截断。 +- 修复参考图片在聊天和图片两种模式下的显示问题。 +- 修复纯文本图片任务被错误标记为通用失败的问题。 +- 修复图片生成模式下上传图片被强制切换模式的问题。 +- 多模态 SSE 载荷解析支持从 "v" 字段提取文本内容。 +- 修复 Dockerfile 引用路径,CI 构建和本地脚本统一使用 `deploy/Dockerfile.release`。 + +### 文档 + +- 图片生成 API 使用说明优化,提升可发现性。 + +## [0.1.7] - 2026-05-08 + +### 新增 + +- 图片生成链路区分官方 `gpt-image-2` 与 `codex-gpt-image-2`:官方模型走标准 `image_generation` 工具语义,Codex 别名走 Codex Responses 路由,并在发送上游前完成模型归一化。 +- Codex 图片生成请求增加 Codex TUI 请求头与模型映射,避免把合成别名直接传给上游工具模型字段。 +- 图片结果支持带会话凭据的鉴权加载与下载,在受保护图片资源下可继续预览、灯箱查看和保存。 +- 图片创作台增加账号能力感知、模型路由标签、尺寸预设展示和更明确的高分辨率提示。 +- 账号刷新接口返回逐账号刷新明细,包括成功状态、耗时、账号信息和失败汇总;管理端账号页同步展示单账号刷新状态、指标卡片和 Token 复制操作。 + +### 变更 + +- 创作并发控制从全局图片槽位改为按用户的创作单元并发限制,统一覆盖图片生成、图片编辑和图片场景聊天任务。 +- 多输出图片任务按单个输出占用创作单元,可更细粒度地展示局部进度和 `output_statuses`。 +- 高分辨率图片请求不再由本地账号类型预先拦截,是否接受由上游服务决定;前端提示也改为说明上游判定。 +- Responses 图片处理从通用 backend 逻辑中拆分为独立模块,协议归一化、SSE 解析和图片输出处理更集中。 +- 图片页、任务队列、弹出菜单和设置页输入控件做了交互整理,移除了过时的 Codex 专用提示文案。 + +### 修复 + +- 对 Responses SSE 传输中断、HTTP/2 `flow_control_error` 等短暂上游连接问题增加有限重试,降低长时间 2K 图片生成因单次断流失败的概率。 +- 修复滚动锁定时页面被额外添加外边距的问题,减少弹窗或灯箱打开时的布局抖动。 +- 改进图片任务耗时显示,任务开始处理后使用更准确的起始时间计算。 +- 扩展刷新结果中的 Token 脱敏范围,避免账号刷新明细泄漏敏感凭据。 + +### 文档 + +- 新增 `jshook/README.md` 作为 ChatGPT Web 逆向研究索引。 +- 新增 gpt-image-2 生成链路、认证 API schema、接口清单、内容类型、函数映射、内部代号和请求完成流程等研究文档。 +- 新增用于验证文本聊天和图片生成完整流程的 `jshook/scripts/` 脚本与脱敏响应样本。 +- README 的技术研究入口改为文档表格,并移除过时的图片架构说明和历史功能状态文档。 + +### 破坏性变更 + +- 移除 `CHATGPT2API_IMAGE_CONCURRENT_LIMIT` 配置项。请改用 `USER_DEFAULT_CONCURRENT_LIMIT` 控制用户默认创作并发额度,该额度现在统一作用于图片生成、图片编辑和图片场景聊天任务。 diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 342fd00b4..000000000 --- a/DESIGN.md +++ /dev/null @@ -1,257 +0,0 @@ -# Design System Inspired by MiniMax - -## 1. Visual Theme & Atmosphere - -MiniMax's website is a clean, product-showcase platform for a Chinese AI technology company that bridges consumer-friendly appeal with technical credibility. The design language is predominantly white-space-driven with a light, airy feel — pure white backgrounds (`#ffffff`) dominate, letting colorful product cards and AI model illustrations serve as the visual anchors. The overall aesthetic sits at the intersection of Apple's product marketing clarity and a playful, rounded design language that makes AI technology feel approachable. - -The typography system is notably multi-font: DM Sans serves as the primary UI workhorse, Outfit handles display headings with geometric elegance, Poppins appears for mid-tier headings, and Roboto handles data-heavy contexts. This variety reflects a brand in rapid growth — each font serves a distinct communicative purpose rather than competing for attention. The hero heading at 80px weight 500 in both DM Sans and Outfit with a tight 1.10 line-height creates a bold but not aggressive opening statement. - -What makes MiniMax distinctive is its pill-button geometry (9999px radius) for navigation and primary actions, combined with softer 8px–24px radiused cards for product showcases. The product cards themselves are richly colorful — vibrant gradients in pink, purple, orange, and blue — creating a "gallery of AI capabilities" feel. Against the white canvas, these colorful cards pop like app icons on a phone home screen, making each AI model/product feel like a self-contained creative tool. - -**Key Characteristics:** -- White-dominant layout with colorful product card accents -- Multi-font system: DM Sans (UI), Outfit (display), Poppins (mid-tier), Roboto (data) -- Pill buttons (9999px radius) for primary navigation and CTAs -- Generous rounded cards (20px–24px radius) for product showcases -- Brand blue spectrum: from `#1456f0` (brand-6) through `#3b82f6` (primary-500) to `#60a5fa` (light) -- Brand pink (`#ea5ec1`) as secondary accent -- Near-black text (`#222222`, `#18181b`) on white backgrounds -- Purple-tinted shadows (`rgba(44, 30, 116, 0.16)`) creating subtle brand-colored depth -- Dark footer section (`#181e25`) with product/company links - -## 2. Color Palette & Roles - -### Brand Primary -- **Brand Blue** (`#1456f0`): `--brand-6`, primary brand identity color -- **Sky Blue** (`#3daeff`): `--col-brand00`, lighter brand variant for accents -- **Brand Pink** (`#ea5ec1`): `--col-brand02`, secondary brand accent - -### Blue Scale (Primary) -- **Primary 200** (`#bfdbfe`): `--color-primary-200`, light blue backgrounds -- **Primary Light** (`#60a5fa`): `--color-primary-light`, active states, highlights -- **Primary 500** (`#3b82f6`): `--color-primary-500`, standard blue actions -- **Primary 600** (`#2563eb`): `--color-primary-600`, hover states -- **Primary 700** (`#1d4ed8`): `--color-primary-700`, pressed/active states -- **Brand Deep** (`#17437d`): `--brand-3`, deep blue for emphasis - -### Text Colors -- **Near Black** (`#222222`): `--col-text00`, primary text -- **Dark** (`#18181b`): Button text, headings -- **Charcoal** (`#181e25`): Dark surface text, footer background -- **Dark Gray** (`#45515e`): `--col-text04`, secondary text -- **Mid Gray** (`#8e8e93`): Tertiary text, muted labels -- **Light Gray** (`#5f5f5f`): `--brand-2`, helper text - -### Surface & Background -- **Pure White** (`#ffffff`): `--col-bg13`, primary background -- **Light Gray** (`#f0f0f0`): Secondary button backgrounds -- **Glass White** (`hsla(0, 0%, 100%, 0.4)`): `--fill-bg-white`, frosted glass overlay -- **Border Light** (`#f2f3f5`): Subtle section dividers -- **Border Gray** (`#e5e7eb`): Component borders - -### Semantic -- **Success Background** (`#e8ffea`): `--success-bg`, positive state backgrounds - -### Shadows -- **Standard** (`rgba(0, 0, 0, 0.08) 0px 4px 6px`): Default card shadow -- **Soft Glow** (`rgba(0, 0, 0, 0.08) 0px 0px 22.576px`): Ambient soft shadow -- **Brand Purple** (`rgba(44, 30, 116, 0.16) 0px 0px 15px`): Brand-tinted glow -- **Brand Purple Offset** (`rgba(44, 30, 116, 0.11) 6.5px 2px 17.5px`): Directional brand glow -- **Card Elevation** (`rgba(36, 36, 36, 0.08) 0px 12px 16px -4px`): Lifted card shadow - -## 3. Typography Rules - -### Font Families -- **Primary UI**: `DM Sans`, with fallbacks: `Helvetica Neue, Helvetica, Arial` -- **Display**: `Outfit`, with fallbacks: `Helvetica Neue, Helvetica, Arial` -- **Mid-tier**: `Poppins` -- **Data/Technical**: `Roboto`, with fallbacks: `Helvetica Neue, Helvetica, Arial` - -### Hierarchy - -| Role | Font | Size | Weight | Line Height | Notes | -|------|------|------|--------|-------------|-------| -| Display Hero | DM Sans / Outfit | 80px (5.00rem) | 500 | 1.10 (tight) | Hero headlines | -| Section Heading | Outfit | 31px (1.94rem) | 600 | 1.50 | Feature section titles | -| Section Heading Alt | Roboto / DM Sans | 32px (2.00rem) | 600 | 0.88 (tight) | Compact headers | -| Card Title | Outfit | 28px (1.75rem) | 500–600 | 1.71 (relaxed) | Product card headings | -| Sub-heading | Poppins | 24px (1.50rem) | 500 | 1.50 | Mid-tier headings | -| Feature Label | Poppins | 18px (1.13rem) | 500 | 1.50 | Feature names | -| Body Large | DM Sans | 20px (1.25rem) | 500 | 1.50 | Emphasized body | -| Body | DM Sans | 16px (1.00rem) | 400–500 | 1.50 | Standard body text | -| Body Bold | DM Sans | 16px (1.00rem) | 700 | 1.50 | Strong emphasis | -| Nav/Link | DM Sans | 14px (0.88rem) | 400–500 | 1.50 | Navigation, links | -| Button Small | DM Sans | 13px (0.81rem) | 600 | 1.50 | Compact buttons | -| Caption | DM Sans / Poppins | 13px (0.81rem) | 400 | 1.70 (relaxed) | Metadata | -| Small Label | DM Sans | 12px (0.75rem) | 500–600 | 1.25–1.50 | Tags, badges | -| Micro | DM Sans / Outfit | 10px (0.63rem) | 400–500 | 1.50–1.80 | Tiny annotations | - -### Principles -- **Multi-font purpose**: DM Sans = UI workhorse (body, nav, buttons); Outfit = geometric display (headings, product names); Poppins = friendly mid-tier (sub-headings, features); Roboto = technical/data contexts. -- **Universal 1.50 line-height**: The overwhelming majority of text uses 1.50 line-height, creating a consistent reading rhythm regardless of font or size. Exceptions: display (1.10 tight) and some captions (1.70 relaxed). -- **Weight 500 as default emphasis**: Most headings use 500 (medium) rather than bold, creating a modern, approachable tone. 600 for section titles, 700 reserved for strong emphasis. -- **Compact hierarchy**: The size scale jumps from 80px display straight to 28–32px section, then 16–20px body — a deliberate compression that keeps the visual hierarchy feeling efficient. - -## 4. Component Stylings - -### Buttons - -**Pill Primary Dark** -- Background: `#181e25` -- Text: `#ffffff` -- Padding: 11px 20px -- Radius: 8px -- Use: Primary CTA ("Get Started", "Learn More") - -**Pill Nav** -- Background: `rgba(0, 0, 0, 0.05)` (subtle tint) -- Text: `#18181b` -- Radius: 9999px (full pill) -- Use: Navigation tabs, filter toggles - -**Pill White** -- Background: `#ffffff` -- Text: `rgba(24, 30, 37, 0.8)` -- Radius: 9999px -- Opacity: 0.5 (default state) -- Use: Secondary nav, inactive tabs - -**Secondary Light** -- Background: `#f0f0f0` -- Text: `#333333` -- Padding: 11px 20px -- Radius: 8px -- Use: Secondary actions - -### Product Cards -- Background: Vibrant gradients (pink/purple/orange/blue) -- Radius: 20px–24px (generous rounding) -- Shadow: `rgba(44, 30, 116, 0.16) 0px 0px 15px` (brand purple glow) -- Content: Product name, model version, descriptive text -- Each card has its own color palette matching the product identity - -### AI Product Cards (Matrix) -- Background: white with subtle shadow -- Radius: 13px–16px -- Shadow: `rgba(0, 0, 0, 0.08) 0px 4px 6px` -- Icon/illustration centered above product name -- Product name in DM Sans 14–16px weight 500 - -### Links -- **Primary**: `#18181b` or `#181e25`, underline on dark text -- **Secondary**: `#8e8e93`, muted for less emphasis -- **On Dark**: `rgba(255, 255, 255, 0.8)` for footer and dark sections - -### Navigation -- Clean horizontal nav on white background -- MiniMax logo left-aligned (red accent in logo) -- DM Sans 14px weight 500 for nav items -- Pill-shaped active indicators (9999px radius) -- "Login" text link, minimal right-side actions -- Sticky header behavior - -## 5. Layout Principles - -### Spacing System -- Base unit: 8px -- Scale: 1px, 2px, 4px, 6px, 8px, 10px, 11px, 14px, 16px, 24px, 32px, 40px, 50px, 64px, 80px - -### Grid & Container -- Max content width centered on page -- Product card grids: horizontal scroll or 3–4 column layout -- Full-width white sections with contained content -- Dark footer at full-width - -### Breakpoints -| Name | Width | Key Changes | -|------|-------|-------------| -| Mobile | <768px | Single column, stacked cards | -| Tablet | 768–1024px | 2-column grids | -| Desktop | >1024px | Full layout, horizontal card scrolls | - -### Whitespace Philosophy -- **Gallery spacing**: Products are presented like gallery items with generous white space between cards, letting each AI model breathe as its own showcase. -- **Section rhythm**: Large vertical gaps (64px–80px) between major sections create distinct "chapters" of content. -- **Card breathing**: Product cards use internal padding of 16px–24px with ample whitespace around text. - -### Border Radius Scale -- Minimal (4px): Small tags, micro badges -- Standard (8px): Buttons, small cards -- Comfortable (11px–13px): Medium cards, panels -- Generous (16px–20px): Large product cards -- Large (22px–24px): Hero product cards, major containers -- Pill (30px–32px): Badge pills, rounded panels -- Full (9999px): Buttons, nav tabs - -## 6. Depth & Elevation - -| Level | Treatment | Use | -|-------|-----------|-----| -| Flat (Level 0) | No shadow | White background, text blocks | -| Subtle (Level 1) | `rgba(0, 0, 0, 0.08) 0px 4px 6px` | Standard cards, containers | -| Ambient (Level 2) | `rgba(0, 0, 0, 0.08) 0px 0px 22.576px` | Soft glow around elements | -| Brand Glow (Level 3) | `rgba(44, 30, 116, 0.16) 0px 0px 15px` | Featured product cards | -| Elevated (Level 4) | `rgba(36, 36, 36, 0.08) 0px 12px 16px -4px` | Lifted cards, hover states | - -**Shadow Philosophy**: MiniMax uses a distinctive purple-tinted shadow (`rgba(44, 30, 116, ...)`) for featured elements, creating a subtle brand-color glow that connects the shadow system to the blue brand identity. Standard shadows use neutral black but at low opacity (0.08), keeping everything feeling light and airy. The directional shadow variant (6.5px offset) adds dimensional interest to hero product cards. - -## 7. Do's and Don'ts - -### Do -- Use white as the dominant background — let product cards provide the color -- Apply pill radius (9999px) for navigation tabs and toggle buttons -- Use generous border radius (20px–24px) for product showcase cards -- Employ the purple-tinted shadow for featured/hero product cards -- Keep body text at DM Sans weight 400–500 — heavier weights for buttons only -- Use Outfit for display headings, DM Sans for everything functional -- Maintain the universal 1.50 line-height across body text -- Let colorful product illustrations/gradients serve as the primary visual interest - -### Don't -- Don't add colored backgrounds to main content sections — white is structural -- Don't use sharp corners (0–4px radius) on product cards — the rounded aesthetic is core -- Don't apply the brand pink (`#ea5ec1`) to text or buttons — it's for logo and decorative accents only -- Don't mix more than one display font per section (Outfit OR Poppins, not both) -- Don't use weight 700 for headings — 500–600 is the range, 700 is reserved for strong emphasis in body text -- Don't darken shadows beyond 0.16 opacity — the light, airy feel requires restraint -- Don't use Roboto for headings — it's the data/technical context font only - -## 8. Responsive Behavior - -### Breakpoints -| Name | Width | Key Changes | -|------|-------|-------------| -| Mobile | <768px | Single column, stacked product cards, hamburger nav | -| Tablet | 768–1024px | 2-column product grids, condensed spacing | -| Desktop | >1024px | Full horizontal card layouts, expanded spacing | - -### Collapsing Strategy -- Hero: 80px → responsive scaling to ~40px on mobile -- Product card grid: horizontal scroll → 2-column → single column stacked -- Navigation: horizontal → hamburger menu -- Footer: multi-column → stacked sections -- Spacing: 64–80px gaps → 32–40px on mobile - -## 9. Agent Prompt Guide - -### Quick Color Reference -- Background: `#ffffff` (primary), `#181e25` (dark/footer) -- Text: `#222222` (primary), `#45515e` (secondary), `#8e8e93` (muted) -- Brand Blue: `#1456f0` (brand), `#3b82f6` (primary-500), `#2563eb` (hover) -- Brand Pink: `#ea5ec1` (accent only) -- Borders: `#e5e7eb`, `#f2f3f5` - -### Example Component Prompts -- "Create a hero section on white background. Headline at 80px Outfit weight 500, line-height 1.10, near-black (#222222) text. Sub-text at 16px DM Sans weight 400, line-height 1.50, #45515e. Dark CTA button (#181e25, 8px radius, 11px 20px padding, white text)." -- "Design a product card grid: white cards with 20px border-radius, shadow rgba(44,30,116,0.16) 0px 0px 15px. Product name at 28px Outfit weight 600. Internal gradient background for the product illustration area." -- "Build navigation bar: white background, DM Sans 14px weight 500 for links, #18181b text. Pill-shaped active tab (9999px radius, rgba(0,0,0,0.05) background). MiniMax logo left-aligned." -- "Create an AI product matrix: 4-column grid of cards with 13px radius, subtle shadow rgba(0,0,0,0.08) 0px 4px 6px. Centered icon above product name in DM Sans 16px weight 500." -- "Design footer on dark (#181e25) background. Product links in DM Sans 14px, rgba(255,255,255,0.8). Multi-column layout." - -### Iteration Guide -1. Start with white — color comes from product cards and illustrations only -2. Pill buttons (9999px) for nav/tabs, standard radius (8px) for CTA buttons -3. Purple-tinted shadows for featured cards, neutral shadows for everything else -4. DM Sans handles 70% of text — Outfit is display-only, Poppins is mid-tier only -5. Keep weights moderate (500–600 for headings) — the brand tone is confident but approachable -6. Large radius cards (20–24px) for products, smaller radius (8–13px) for UI elements diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ef86f43d0..000000000 --- a/Dockerfile +++ /dev/null @@ -1,60 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -ARG VERSION=0.0.0-dev - -FROM --platform=$BUILDPLATFORM oven/bun:1-alpine AS web-deps - -WORKDIR /app/web - -COPY web/package.json web/bun.lock ./ -RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \ - bun install --frozen-lockfile - - -FROM web-deps AS web-build - -COPY web ./ -ARG VERSION=0.0.0-dev -ENV VITE_APP_VERSION=${VERSION} -RUN bun run build - - -FROM --platform=$BUILDPLATFORM golang:1.26.2-bookworm AS go-build - -WORKDIR /src - -COPY go.mod go.sum ./ -RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked go mod download - -COPY cmd ./cmd -COPY internal ./internal -COPY --from=web-build /app/internal/web/dist ./internal/web/dist -ARG TARGETOS -ARG TARGETARCH -ARG VERSION=0.0.0-dev -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build -trimpath -tags=embed -ldflags="-s -w -X chatgpt2api/internal/version.Version=${VERSION}" -o /out/chatgpt2api ./cmd/chatgpt2api - - -FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS app - -WORKDIR /app -ENV PORT=80 -ENV CHATGPT2API_DEPLOYMENT=docker - -# 运行时依赖: -# - ca-certificates: HTTPS 上游请求需要 -# - git: Git 存储后端需要 -# - tzdata: 保持容器内时区数据可用 -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - git \ - tzdata \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=go-build /out/chatgpt2api ./chatgpt2api - -EXPOSE 80 - -CMD ["/app/chatgpt2api"] diff --git a/README.md b/README.md index 454dacd4b..58fd47427 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ | 立即部署服务 | [快速部署](#快速部署) | | 配置管理员、代理、并发、存储 | [配置说明](#配置说明) | | 创建 API Token 并调用接口 | [API 接入](#api-接入) | +| 查看生图参数、异步任务和错误码 | [生图接口文档](./docs/image-generation-api.md) | | 本地改代码和验证构建 | [本地开发](#本地开发) | | 升级 Docker 镜像或 Release 二进制 | [升级与在线更新](#升级与在线更新) | | ChatGPT 官网生图协议研究 | [技术研究文档](#技术研究文档) / [jshook 索引](./jshook/README.md) | @@ -102,7 +103,7 @@ CHATGPT2API_ADMIN_PASSWORD=change_me_please ### 2. 启动服务 ```bash -docker compose up -d +docker compose -f deploy/docker-compose.yml up -d ``` 默认 Compose 配置: @@ -119,16 +120,16 @@ docker compose up -d http://localhost:3000 ``` -查看日志(需要在 `docker-compose.yml` 所在目录执行): +查看日志(需要在仓库根目录执行): ```bash -docker compose logs -f app +docker compose -f deploy/docker-compose.yml logs -f app ``` -查看自动生成的管理员密码(需要在 `docker-compose.yml` 所在目录执行): +查看自动生成的管理员密码(需要在仓库根目录执行): ```bash -docker compose logs app | grep "bootstrap admin password generated" +docker compose -f deploy/docker-compose.yml logs app | grep "bootstrap admin password generated" ``` 日志行格式: @@ -143,7 +144,7 @@ bootstrap admin password generated: username=admin password=生成的密码 Windows PowerShell: ```powershell -docker compose logs app | Select-String "bootstrap admin password generated" +docker compose -f deploy/docker-compose.yml logs app | Select-String "bootstrap admin password generated" ``` 默认容器名方式: @@ -152,7 +153,7 @@ docker compose logs app | Select-String "bootstrap admin password generated" docker logs chatgpt2api 2>&1 | grep "bootstrap admin password generated" ``` -如果提示 `no configuration file provided: not found`,说明当前目录没有 Compose 配置文件。先进入部署目录再执行 `docker compose logs app`,或直接使用上面的 `docker logs chatgpt2api ...` 命令。 +如果提示 `no configuration file provided: not found`,说明当前命令没有指定 Compose 配置文件。先进入仓库根目录再执行 `docker compose -f deploy/docker-compose.yml logs app`,或直接使用上面的 `docker logs chatgpt2api ...` 命令。 如果查不到日志,先确认 `.env` 或容器环境里是否已经设置了固定密码: @@ -175,7 +176,7 @@ cd /opt/chatgpt2api # 编辑 .env,设置一个新的已知管理员密码: # CHATGPT2API_ADMIN_PASSWORD=your_new_password -docker compose down +docker compose -f deploy/docker-compose.yml down cp -a data "data.bak.$(date +%Y%m%d-%H%M%S)" python3 - <<'PY' import sqlite3 @@ -191,28 +192,39 @@ con.commit() print(f"removed auth_users.json rows: {cur.rowcount}") con.close() PY -docker compose up -d +docker compose -f deploy/docker-compose.yml up -d ``` -如果使用 `STORAGE_BACKEND=json`,本地登录账号保存在 `data/auth_users.json`,可在备份后删除该文件再重启: + + +### 3. 服务器源码构建(可选) + +发布镜像由 GitHub Actions 构建。如果你需要在自己的服务器上从当前源码构建镜像,使用受限 BuildKit 脚本: ```bash -docker compose down -cp -a data "data.bak.$(date +%Y%m%d-%H%M%S)" -rm -f data/auth_users.json -docker compose up -d +sh deploy/docker-build-limited.sh up ``` - +该脚本会创建独立的 `docker-container` Buildx builder,并对构建容器设置 CPU / 内存上限。直接运行时会按服务器资源自动选择默认值:最多使用 2 核;内存充足时默认放开到 3-4 GB;低内存机器才会降低 Go 编译并行度,避免 `compile: signal: killed` 这类 OOM: -### 3. 自建镜像 +```bash +sh deploy/docker-build-limited.sh up +``` + +如果你想显式放开配额: + +```bash +BUILD_CPUS=2 BUILD_MEMORY=4g BUILD_MEMORY_SWAP=4g BUILD_GOMAXPROCS=2 BUILD_GOMEMLIMIT=2GiB sh deploy/docker-build-limited.sh up +``` -如果需要从当前源码构建本地镜像: +如果只想构建本地镜像、不重启容器: ```bash -docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build +sh deploy/docker-build-limited.sh build ``` +脚本使用 `deploy/Dockerfile` 从源码构建本地镜像,默认镜像名为 `chatgpt2api:local`;`up` 模式会继续用 `deploy/docker-compose.yml` 启动该本地镜像。 + ## 升级与在线更新 ### Docker 镜像升级 @@ -220,11 +232,11 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build Docker 部署的推荐升级方式: ```bash -docker compose pull -docker compose up -d +docker compose -f deploy/docker-compose.yml pull +docker compose -f deploy/docker-compose.yml up -d ``` -默认 Compose 使用 DockerHub 公共镜像,普通用户不需要配置 GitHub Release 源、GitHub Token,也不需要登录 GitHub。也可以按需将 `docker-compose.yml` 的 `image` 改为 GHCR: +默认 Compose 使用 DockerHub 公共镜像,普通用户不需要配置 GitHub Release 源、GitHub Token,也不需要登录 GitHub。也可以按需将 `deploy/docker-compose.yml` 的 `image` 改为 GHCR: ```yaml image: ghcr.io/zyphrzero/chatgpt2api:latest @@ -241,7 +253,7 @@ ghcr.io/zyphrzero/chatgpt2api:latest 设置页的“版本更新”卡片会按部署方式选择更新来源: -- Docker 镜像:默认匿名检查 DockerHub 公共镜像标签,升级方式是 `docker compose pull && docker compose up -d`。 +- Docker 镜像:默认匿名检查 DockerHub 公共镜像标签,升级方式是 `docker compose -f deploy/docker-compose.yml pull && docker compose -f deploy/docker-compose.yml up -d`。 - Release 二进制:检查项目 GitHub Release,只有这种非 Docker 部署会显示“立即更新”并替换当前 `chatgpt2api` 二进制。 Release 二进制在线更新流程: @@ -257,12 +269,11 @@ Release 二进制在线更新流程: 重要说明: - Docker 部署默认从 DockerHub 拉取镜像,不需要填写 GitHub Release 源或 GitHub Token。 -- Docker 容器内不会执行二进制替换;请用 `docker compose pull && docker compose up -d` 更新镜像。 +- Docker 容器内不会执行二进制替换;请用 `docker compose -f deploy/docker-compose.yml pull && docker compose -f deploy/docker-compose.yml up -d` 更新镜像。 - 在线二进制替换只在非 Docker 的 `BuildType=release` 构建中开放。 - 前端资源已嵌入 Release 二进制,在线更新只替换 `chatgpt2api` 这一个运行文件。 - 检查更新访问 DockerHub / Release API 可通过 `CHATGPT2API_UPDATE_PROXY_URL` 配置代理;未设置时复用 `CHATGPT2API_PROXY`。 - 正式 Release archive 只发布 Linux `amd64` / `arm64` 构建;Windows 和 macOS 不提供在线更新压缩包。 -- 简化发布只推送 Docker 镜像,不上传二进制压缩包时,在线更新无法找到可下载的 Release archive。 ### 源码部署升级 @@ -273,7 +284,7 @@ git pull bun install --cwd web --frozen-lockfile bun --cwd web run build go test ./... -go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./cmd/chatgpt2api +go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./internal ``` ## 配置说明 @@ -304,8 +315,8 @@ go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `STORAGE_BACKEND` | `sqlite` | 存储后端,可选 `sqlite`、`json`、`postgres` | -| `DATABASE_URL` | 自动 | SQLite 或 PostgreSQL 连接串 | +| `STORAGE_BACKEND` | `sqlite` | 存储后端,可选 `sqlite`、`postgres`、`mysql` | +| `DATABASE_URL` | 自动 | SQLite、PostgreSQL 或 MySQL 连接串 | SQLite 示例: @@ -321,7 +332,14 @@ STORAGE_BACKEND=postgres DATABASE_URL=postgresql://user:password@host:5432/chatgpt2api ``` -新部署默认使用 SQLite,并自动创建 `data/chatgpt2api.db`。如果已有历史 JSON 部署,切换存储后端前请先备份 `data/`,再通过管理端导出号池 Token、切换 `STORAGE_BACKEND=sqlite`、重启服务并重新导入;本仓库当前不提供独立的 JSON 到 SQLite 离线迁移脚本。本地登录用户、角色、API 令牌和设置类 JSON 文档不会自动迁移,切换后需要重新初始化或手动重建。历史 `logs/events.jsonl` 不会自动写入 SQLite,新日志会在数据库后端启用后写入数据库。 +MySQL 示例: + +```env +STORAGE_BACKEND=mysql +DATABASE_URL=mysql://user:password@host:3306/chatgpt2api +``` + +新部署默认使用 SQLite,并自动创建 `data/chatgpt2api.db`。本地 JSON 文件存储后端已移除,`STORAGE_BACKEND=json` 不再支持。 ### Linuxdo 登录 @@ -349,7 +367,7 @@ CHATGPT2API_LINUXDO_FRONTEND_REDIRECT_URL=/auth/linuxdo/callback bun install --cwd web --frozen-lockfile bun --cwd web run build go test ./... -go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=0.0.0-dev" -o chatgpt2api ./cmd/chatgpt2api +go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=0.0.0-dev" -o chatgpt2api ./internal CHATGPT2API_ADMIN_PASSWORD=change_me_please ./chatgpt2api ``` @@ -398,6 +416,7 @@ bun run build - `go test ./...` - `bun install --frozen-lockfile` - `bun run build` +- `docker compose -f deploy/docker-compose.yml config` ### Release @@ -408,7 +427,7 @@ bun run build 3. 将前端 artifact 下载到 `internal/web/dist`。 4. GoReleaser 使用 `-tags=embed` 构建 Linux `amd64` / `arm64` 二进制。 5. 生成 GitHub Release archive 和 `checksums.txt`。 -6. 使用 `Dockerfile.goreleaser` 构建多架构 Docker 镜像。 +6. 使用 `deploy/Dockerfile.release` 构建多架构 Docker 镜像。 7. 推送 DockerHub 镜像。 8. 推送 GHCR 镜像。 @@ -454,6 +473,8 @@ Authorization: Bearer 后台登录后可以在个人资料或用户管理中创建 API 令牌。 +图片生成、图片编辑、异步创作任务、轮询、取消、输出格式、文本型结果和错误码的完整说明见 [生图接口文档](./docs/image-generation-api.md) + ### 常用接口 | 方法 | 路径 | 说明 | @@ -643,14 +664,10 @@ Telegram 群组:[ChatGPT2API](https://t.me/+YBR7t_CPOYBkYzU1) 学 AI,上 L 站:[LinuxDO](https://linux.do) -内置提示词参考: - - [banana-prompt-quicker](https://github.com/glidea/banana-prompt-quicker),作者:[阿良](https://linux.do/u/ajd) - [awesome-gpt-image-2-prompts](https://github.com/EvoLinkAI/awesome-gpt-image-2-prompts) - -生图配置参考: - - [ChatGpt-Image-Studio](https://github.com/peiyizhi0724/ChatGpt-Image-Studio),作者:[小怪兽](https://linux.do/u/peiyizhi) +- [sub2api](https://github.com/Wei-Shaw/sub2api) ## Contributors diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 000000000..954aed29a --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1.7 + +ARG VERSION=0.0.0-dev +ARG BUN_IMAGE=oven/bun:1.3.13-alpine +ARG GO_IMAGE=golang:1.26.2-bookworm +ARG RUNTIME_IMAGE=debian:bookworm-slim + +FROM --platform=$BUILDPLATFORM ${BUN_IMAGE} AS web-deps + +WORKDIR /app/web +ENV CI=1 + +COPY web/package.json web/bun.lock ./ +RUN bun pm cache rm || true +RUN --mount=type=cache,id=bun-install-cache-v2,target=/root/.bun/install/cache,sharing=locked \ + bun install --frozen-lockfile + + +FROM web-deps AS web-build + +COPY web ./ +ARG VERSION=0.0.0-dev +ARG BUILD_NODE_OPTIONS=--max-old-space-size=1024 +ENV VITE_APP_VERSION=${VERSION} \ + NODE_OPTIONS=${BUILD_NODE_OPTIONS} +RUN bun run build + + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked go mod download + +COPY internal ./internal +COPY --from=web-build /app/internal/web/dist ./internal/web/dist +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=0.0.0-dev +ARG BUILD_GOMAXPROCS=2 +ARG BUILD_GOMEMLIMIT=2GiB +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ + GOMAXPROCS=${BUILD_GOMAXPROCS} GOMEMLIMIT=${BUILD_GOMEMLIMIT} \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -p=${BUILD_GOMAXPROCS} -trimpath -tags=embed \ + -ldflags="-s -w -X chatgpt2api/internal/version.Version=${VERSION}" \ + -o /out/chatgpt2api ./internal + + +FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS app + +WORKDIR /app +ENV PORT=80 +ENV CHATGPT2API_DEPLOYMENT=docker + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=go-build /out/chatgpt2api ./chatgpt2api + +RUN mkdir -p /app/data && chmod +x /app/chatgpt2api + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${PORT:-80}/health || exit 1 + +CMD ["/app/chatgpt2api"] diff --git a/deploy/Dockerfile.dev b/deploy/Dockerfile.dev new file mode 100644 index 000000000..5646c1d12 --- /dev/null +++ b/deploy/Dockerfile.dev @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.7 + +ARG VERSION=0.0.0-dev +ARG GO_IMAGE=golang:1.26.2-bookworm +ARG RUNTIME_IMAGE=debian:bookworm-slim + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked go mod download + +COPY internal ./internal +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=0.0.0-dev +ARG BUILD_GOMAXPROCS=2 +ARG BUILD_GOMEMLIMIT=2GiB +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ + GOMAXPROCS=${BUILD_GOMAXPROCS} GOMEMLIMIT=${BUILD_GOMEMLIMIT} \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -p=${BUILD_GOMAXPROCS} -trimpath -tags=embed \ + -ldflags="-s -w -X chatgpt2api/internal/version.Version=${VERSION}" \ + -o /out/chatgpt2api ./internal + + +FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS app + +WORKDIR /app +ENV PORT=80 +ENV CHATGPT2API_DEPLOYMENT=docker + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=go-build /out/chatgpt2api ./chatgpt2api + +RUN mkdir -p /app/data && chmod +x /app/chatgpt2api + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${PORT:-80}/health || exit 1 + +CMD ["/app/chatgpt2api"] diff --git a/.dockerignore b/deploy/Dockerfile.dev.dockerignore similarity index 83% rename from .dockerignore rename to deploy/Dockerfile.dev.dockerignore index 758c432d3..e562d3ca9 100644 --- a/.dockerignore +++ b/deploy/Dockerfile.dev.dockerignore @@ -6,6 +6,7 @@ .DS_Store .claude .omx +.codex .ace-tool .github @@ -25,6 +26,7 @@ build dist web_dist +web web/node_modules web/dist web/.next @@ -45,8 +47,12 @@ AGENTS.md DESIGN.md LICENSE docker-compose*.yml +deploy/docker-compose.yml +deploy/Dockerfile.release +deploy/docker-build-limited.sh scripts +venv .venv __pycache__ *.pyc diff --git a/deploy/Dockerfile.dockerignore b/deploy/Dockerfile.dockerignore new file mode 100644 index 000000000..1f6adb6a9 --- /dev/null +++ b/deploy/Dockerfile.dockerignore @@ -0,0 +1,61 @@ +.git +.gitignore +.gitattributes +.idea +.vscode +.DS_Store +.claude +.omx +.codex +.ace-tool +.github + +.env +.env.* +!.env.example + +data +git_cache +*.db +*.sqlite +*.sqlite3 + +chatgpt2api +chatgpt2api.exe +build +dist +web_dist + +web/node_modules +web/dist +web/.next +web/out +web/build +web/coverage +web/.vercel +web/*.tsbuildinfo +web/npm-debug.log* +web/yarn-debug.log* +web/yarn-error.log* +web/pnpm-debug.log* + +internal/web/dist + +assets +docs +README.md +AGENTS.md +DESIGN.md +LICENSE +docker-compose*.yml +deploy/docker-compose.yml +deploy/Dockerfile.release +deploy/docker-build-limited.sh +scripts + +venv +.venv +__pycache__ +*.pyc +*.pyo +*.pyd diff --git a/Dockerfile.goreleaser b/deploy/Dockerfile.release similarity index 69% rename from Dockerfile.goreleaser rename to deploy/Dockerfile.release index b6c53b68e..e4b9b6625 100644 --- a/Dockerfile.goreleaser +++ b/deploy/Dockerfile.release @@ -1,7 +1,11 @@ # syntax=docker/dockerfile:1.7 +# +# Release-only runtime image. GoReleaser builds the chatgpt2api binary first, +# then uses this Dockerfile to package that binary for DockerHub/GHCR. FROM debian:bookworm-slim +ARG TARGETPLATFORM WORKDIR /app ENV PORT=80 ENV CHATGPT2API_DEPLOYMENT=docker @@ -13,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata \ && rm -rf /var/lib/apt/lists/* -COPY chatgpt2api /app/chatgpt2api +COPY $TARGETPLATFORM/chatgpt2api /app/chatgpt2api RUN mkdir -p /app/data && chmod +x /app/chatgpt2api diff --git a/deploy/docker-build-limited.sh b/deploy/docker-build-limited.sh new file mode 100644 index 000000000..ceaef83f8 --- /dev/null +++ b/deploy/docker-build-limited.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env sh +set -eu + +usage() { + cat <<'EOF' +Usage: + sh deploy/docker-build-limited.sh [up|build] + +Creates a resource-capped BuildKit builder, then builds the local Docker image. + +Tunable environment variables: + CHATGPT2API_BUILDER_NAME Builder name (default: chatgpt2api-local-build) + BUILD_CPUS Whole CPU cores available to BuildKit (default: auto, up to 2) + BUILD_MEMORY BuildKit memory limit (default: auto, 2g-4g) + BUILD_MEMORY_SWAP BuildKit memory+swap limit (default: auto, 4g when possible) + BUILDKIT_MAX_PARALLELISM BuildKit solver parallelism (default: BUILD_CPUS) + BUILD_GOMAXPROCS Go compiler parallelism (default: auto, 1 on low-memory hosts, otherwise BUILD_CPUS) + BUILD_GOMEMLIMIT Go soft memory limit (default: auto) + BUILD_NODE_OPTIONS Node options for the web build + BUILD_CPUSET_CPUS Optional cpuset, for example 0-1 + +Examples: + sh deploy/docker-build-limited.sh up + BUILD_CPUS=2 BUILD_MEMORY=4g BUILD_MEMORY_SWAP=4g BUILD_GOMAXPROCS=2 BUILD_GOMEMLIMIT=2GiB sh deploy/docker-build-limited.sh up +EOF +} + +require_uint() { + name="$1" + value="$2" + case "$value" in + ''|*[!0-9]*) + echo "$name must be a positive integer, got: $value" >&2 + exit 2 + ;; + 0) + echo "$name must be greater than zero" >&2 + exit 2 + ;; + esac +} + +command="${1:-up}" +case "$command" in + up|build) + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; +esac + +detect_cpu_count() { + if command -v nproc >/dev/null 2>&1; then + nproc + elif command -v getconf >/dev/null 2>&1; then + getconf _NPROCESSORS_ONLN + else + echo 2 + fi +} + +detect_memory_mib() { + if [ -r /proc/meminfo ]; then + awk '/MemTotal:/ { print int($2 / 1024); exit }' /proc/meminfo + else + echo 4096 + fi +} + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) + +detected_cpus="$(detect_cpu_count)" +require_uint detected_cpus "$detected_cpus" +if [ "$detected_cpus" -gt 2 ]; then + default_build_cpus=2 +else + default_build_cpus="$detected_cpus" +fi + +detected_memory_mib="$(detect_memory_mib)" +require_uint detected_memory_mib "$detected_memory_mib" +if [ "$detected_memory_mib" -ge 6144 ]; then + default_build_memory=4g + default_build_memory_swap=4g + default_buildkit_max_parallelism="$default_build_cpus" + default_build_gomaxprocs="$default_build_cpus" + default_build_gomemlimit=2GiB + default_build_node_options=--max-old-space-size=1024 +elif [ "$detected_memory_mib" -ge 4096 ]; then + default_build_memory=3g + default_build_memory_swap=4g + default_buildkit_max_parallelism="$default_build_cpus" + default_build_gomaxprocs="$default_build_cpus" + default_build_gomemlimit=1536MiB + default_build_node_options=--max-old-space-size=1024 +else + default_build_memory=2g + default_build_memory_swap=4g + default_buildkit_max_parallelism=1 + default_build_gomaxprocs=1 + default_build_gomemlimit=1GiB + default_build_node_options=--max-old-space-size=768 +fi + +builder_name="${CHATGPT2API_BUILDER_NAME:-chatgpt2api-local-build}" +build_cpus="${BUILD_CPUS:-$default_build_cpus}" +build_cpu_period="${BUILD_CPU_PERIOD:-100000}" +build_memory="${BUILD_MEMORY:-$default_build_memory}" +build_memory_swap="${BUILD_MEMORY_SWAP:-$default_build_memory_swap}" +buildkit_max_parallelism="${BUILDKIT_MAX_PARALLELISM:-$default_buildkit_max_parallelism}" + +require_uint BUILD_CPUS "$build_cpus" +require_uint BUILD_CPU_PERIOD "$build_cpu_period" +require_uint BUILDKIT_MAX_PARALLELISM "$buildkit_max_parallelism" + +build_cpu_quota="${BUILD_CPU_QUOTA:-$((build_cpus * build_cpu_period))}" +require_uint BUILD_CPU_QUOTA "$build_cpu_quota" + +export DOCKER_BUILDKIT=1 +export BUILDX_BUILDER="$builder_name" +export BUILD_GOMAXPROCS="${BUILD_GOMAXPROCS:-$default_build_gomaxprocs}" +export BUILD_GOMEMLIMIT="${BUILD_GOMEMLIMIT:-$default_build_gomemlimit}" +export BUILD_NODE_OPTIONS="${BUILD_NODE_OPTIONS:-$default_build_node_options}" +export CHATGPT2API_LOCAL_IMAGE="${CHATGPT2API_LOCAL_IMAGE:-chatgpt2api:local}" +export CHATGPT2API_VERSION="${CHATGPT2API_VERSION:-0.0.0-dev}" + +require_uint BUILD_GOMAXPROCS "$BUILD_GOMAXPROCS" + +cache_root="${XDG_CACHE_HOME:-${HOME:-.}/.cache}/chatgpt2api-buildkit" +mkdir -p "$cache_root" +buildkit_config="$cache_root/buildkitd.toml" +fingerprint_file="$cache_root/$builder_name.options" + +cat > "$buildkit_config" </dev/null + else + docker buildx create \ + --name "$builder_name" \ + --driver docker-container \ + --driver-opt "image=moby/buildkit:buildx-stable-1" \ + --driver-opt "cpu-period=$build_cpu_period" \ + --driver-opt "cpu-quota=$build_cpu_quota" \ + --driver-opt "memory=$build_memory" \ + --driver-opt "memory-swap=$build_memory_swap" \ + --buildkitd-config "$buildkit_config" \ + --use \ + --bootstrap >/dev/null + fi + printf '%s' "$fingerprint" > "$fingerprint_file" +} + +if docker buildx inspect "$builder_name" >/dev/null 2>&1; then + if [ ! -f "$fingerprint_file" ] || [ "$(cat "$fingerprint_file")" != "$fingerprint" ]; then + docker buildx rm --keep-state "$builder_name" >/dev/null 2>&1 || docker buildx rm "$builder_name" >/dev/null + create_builder + else + docker buildx use "$builder_name" >/dev/null + docker buildx inspect --bootstrap "$builder_name" >/dev/null + fi +else + create_builder +fi + +docker buildx build \ + --builder "$builder_name" \ + --load \ + --tag "$CHATGPT2API_LOCAL_IMAGE" \ + --file "$repo_root/deploy/Dockerfile" \ + --build-arg "VERSION=$CHATGPT2API_VERSION" \ + --build-arg "BUILD_GOMAXPROCS=$BUILD_GOMAXPROCS" \ + --build-arg "BUILD_GOMEMLIMIT=$BUILD_GOMEMLIMIT" \ + --build-arg "BUILD_NODE_OPTIONS=$BUILD_NODE_OPTIONS" \ + "$repo_root" + +if [ "$command" = "up" ]; then + CHATGPT2API_DATA_DIR="$repo_root/data" \ + CHATGPT2API_ENV_FILE="$repo_root/.env" \ + CHATGPT2API_IMAGE="$CHATGPT2API_LOCAL_IMAGE" \ + CHATGPT2API_PULL_POLICY=never \ + docker compose --env-file "$repo_root/.env" -f "$repo_root/deploy/docker-compose.yml" up -d --no-build +fi diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 000000000..5abb2bbbd --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,13 @@ +services: + app: + image: ${CHATGPT2API_IMAGE:-zyphrzero/chatgpt2api:latest} + pull_policy: ${CHATGPT2API_PULL_POLICY:-always} + container_name: chatgpt2api + restart: unless-stopped + ports: + - "3000:80" + env_file: + - ${CHATGPT2API_ENV_FILE:-../.env} + volumes: + - ${CHATGPT2API_DATA_DIR:-../data}:/app/data + - ${CHATGPT2API_ENV_FILE:-../.env}:/app/.env diff --git a/docker-compose.build.yml b/docker-compose.build.yml deleted file mode 100644 index cb33e3741..000000000 --- a/docker-compose.build.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - app: - build: - context: . - dockerfile: Dockerfile - args: - VERSION: ${CHATGPT2API_VERSION:-0.0.0-dev} - image: ${CHATGPT2API_LOCAL_IMAGE:-chatgpt2api:local} - pull_policy: build diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index 6bdb8cb47..000000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,35 +0,0 @@ -services: - app: - build: - context: . - dockerfile: Dockerfile - args: - VERSION: ${CHATGPT2API_VERSION:-0.0.0-dev} - image: chatgpt2api:local - container_name: chatgpt2api-local - ports: - - "8000:80" - env_file: - - .env - volumes: - - ./data:/app/data - - ./.env:/app/.env - environment: - STORAGE_BACKEND: sqlite - DATABASE_URL: sqlite:////app/data/chatgpt2api.db - # 存储后端配置 (可选值: json, sqlite, postgres, git) - # environment: - # STORAGE_BACKEND: json - - # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用) - # DATABASE_URL: postgresql://user:password@host:5432/dbname - # DATABASE_URL: sqlite:////app/data/chatgpt2api.db - - # 初始管理员密码 (可选,覆盖 .env) - # CHATGPT2API_ADMIN_PASSWORD: change_me_please - - # 基础 URL (可选) - # CHATGPT2API_BASE_URL: https://your-domain.com - - # 在线更新代理 (可选,未设置时复用 CHATGPT2API_PROXY) - # CHATGPT2API_UPDATE_PROXY_URL: http://127.0.0.1:7890 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index efb30db9b..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - app: - image: zyphrzero/chatgpt2api:latest - pull_policy: always - container_name: chatgpt2api - restart: unless-stopped - ports: - - "3000:80" - env_file: - - .env - volumes: - - ./data:/app/data - - ./.env:/app/.env - environment: - # 存储后端配置 (可选值: json, sqlite, postgres, git) - - STORAGE_BACKEND=${STORAGE_BACKEND:-sqlite} - - # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用) - # - DATABASE_URL=postgresql://user:password@host:5432/dbname - # - DATABASE_URL=sqlite:////app/data/chatgpt2api.db - - # 初始管理员密码 (可选,覆盖 .env) - # - CHATGPT2API_ADMIN_PASSWORD=change_me_please - - # 基础 URL (可选) - # - CHATGPT2API_BASE_URL=https://your-domain.com - - # 检查更新代理 (可选,未设置时复用 CHATGPT2API_PROXY) - - CHATGPT2API_UPDATE_PROXY_URL=${CHATGPT2API_UPDATE_PROXY_URL:-} diff --git a/docs/image-generation-api.md b/docs/image-generation-api.md new file mode 100644 index 000000000..f8ed238fb --- /dev/null +++ b/docs/image-generation-api.md @@ -0,0 +1,492 @@ +# 生图接口文档 + +本文档描述当前服务已实现的图片生成、图片编辑和异步创作任务接口。接口分为两组: + +- OpenAI 兼容同步接口:`/v1/images/generations`、`/v1/images/edits`。 +- Web 端异步任务接口:`/api/creation-tasks/image-generations`、`/api/creation-tasks/image-edits`、查询与取消任务接口。 + +同步接口适合外部 OpenAI SDK 或简单脚本直接调用;异步任务接口适合 Web 创作台、轮询进度、多图并发、任务取消和结果留存。 + +## 认证 + +所有受保护的 AI 接口都需要认证。推荐使用请求头: + +```http +Authorization: Bearer +``` + +也可以由浏览器会话 Cookie 完成认证。普通用户还需要具备对应 API 权限;异步创作任务的权限入口是 `GET /api/creation-tasks` 和 `POST /api/creation-tasks`,子路径按同一资源权限生效。 + +## 模型与链路 + +图片任务模型主要使用: + +| 模型 | 链路 | 说明 | +| --- | --- | --- | +| `auto` | 官方图片工具 | 默认等价 `gpt-image-2`。 | +| `gpt-image-2` | 官方图片工具 | 走 ChatGPT 官网 `f/conversation` 图片链路。尺寸更接近构图提示,实际像素以上游返回为准。 | +| `codex-gpt-image-2` | Codex 图片链路 | 走 Codex Responses 图片链路,结构化尺寸、格式、JPEG 压缩等参数更直接交给上游工具处理。通常需要 Plus、Team 或 Pro 账号。 | + +`/v1/models` 可能返回更多文本模型,但图片生成/图片编辑接口只应使用上述图片任务模型。 + +## 通用参数 + +| 字段 | 类型 | 默认值 | 适用接口 | 说明 | +| --- | --- | --- | --- | --- | +| `prompt` | string | 无 | 全部 | 生图或编辑提示词。生成接口必填;编辑接口也建议必填。 | +| `model` | string | `auto` | 全部 | 图片任务模型:`auto`、`gpt-image-2`、`codex-gpt-image-2`。 | +| `n` | number | `1` | 全部 | 生成数量。同步接口要求 `1-4`;异步任务会归一化到 `1-4`。 | +| `size` | string | 空 | 全部 | 支持 `auto`、比例值、档位和显式尺寸。详见“尺寸”。 | +| `quality` | string | 空 | 全部 | 可传 `low`、`medium`、`high`。当前前端不强制启用质量控制,部分链路仅作为提示或上游参数。 | +| `response_format` | string | 同步为 `b64_json`,异步为 `url` | 同步、内部任务 payload | 同步接口可用 `b64_json`;异步任务固定面向 URL 结果。 | +| `output_format` | string | `png` | 全部 | 输出保存格式。支持 `png`、`jpeg`、`webp`,`jpg` 会归一化为 `jpeg`。非法值归一化为 `png`。 | +| `output_compression` | number | 空 | 全部 | 仅 `output_format=jpeg` 时生效,范围 `0-100`,超过 `100` 会按 `100` 处理。 | +| `background` | string | 空 | 全部 | 透传给图片工具的背景参数,例如 `transparent`。实际支持取决于上游链路。 | +| `moderation` | string | 空 | 全部 | 透传给图片工具的审核参数。实际支持取决于上游链路。 | +| `style` | string | 空 | 全部 | 透传给图片工具的风格参数。实际支持取决于上游链路。 | +| `partial_images` | number | 空 | 全部 | 正整数时启用部分图片/进度图片相关参数。 | +| `input_image_mask` | string | 空 | 编辑接口 | 图生图遮罩。通常传 data URL 或 base64 内容,实际支持取决于上游链路。 | +| `visibility` | string | `private` | 全部 | 生成图片入库可见性。支持 `private`、`public`。影响图库展示,不影响上游生成语义。 | +| `messages` | array | 空 | 全部 | 当前会被透传/归一化,但不要把它理解为可靠的“图片上下文记忆”。详见“上下文边界”。 | +| `stream` | boolean | `false` | 同步接口 | 为 `true` 时返回 SSE。 | + +## 尺寸 + +`size` 支持以下写法: + +| 写法 | 说明 | +| --- | --- | +| `auto` | 不强制尺寸,由上游决定。 | +| `1080p` | 归一化为 `1080x1080`。 | +| `2k` | 归一化为 `2048x2048`。 | +| `4k` | 归一化为 `2880x2880`。 | +| `1:1`、`3:2`、`2:3`、`16:9`、`21:9`、`9:16`、`4:3`、`3:4` | 作为构图比例提示。 | +| `1024x1024`、`1536x2048` | 显式宽高。官方图片工具链路会把它作为构图/目标尺寸提示,实际像素仍以上游返回为准。 | + +异步任务还支持 `image_resolution` 元数据字段,取值为 `1080p`、`2k`、`4k`。该字段用于记录分辨率档位和图库元数据,不替代 `size`。 + +## 同步文生图 + +### `POST /v1/images/generations` + +请求体格式:`application/json` + +必填字段: + +- `prompt` + +示例: + +```bash +curl http://localhost:3000/v1/images/generations \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "model": "auto", + "prompt": "一张雨夜东京街头的赛博朋克猫,霓虹灯反射在地面", + "n": 1, + "size": "16:9", + "output_format": "png", + "response_format": "b64_json" + }' +``` + +成功响应: + +```json +{ + "created": 1778470000, + "data": [ + { + "url": "http://localhost:3000/images/2026/05/11/example.png", + "b64_json": "", + "revised_prompt": "一张雨夜东京街头的赛博朋克猫,霓虹灯反射在地面", + "output_format": "png" + } + ] +} +``` + +说明: + +- `response_format=b64_json` 时返回 `b64_json`,同时仍会保存图片并返回 `url`。 +- `response_format` 不为 `b64_json` 时,响应项通常只有 `url`、`revised_prompt`、`output_format`。 +- 请求会记录生成图片;`visibility` 控制这些图片在图库中的默认可见性。 + +## 同步图生图 + +### `POST /v1/images/edits` + +请求体格式:`multipart/form-data` + +必填字段: + +- `image` 或 `image[]`:至少一个图片文件。 +- `prompt`:编辑提示词。 + +示例: + +```bash +curl http://localhost:3000/v1/images/edits \ + -H "Authorization: Bearer " \ + -F "model=auto" \ + -F "prompt=把这张图改成赛博朋克夜景风格,保留主体轮廓" \ + -F "n=1" \ + -F "size=1024x1024" \ + -F "output_format=jpeg" \ + -F "output_compression=85" \ + -F "image=@./input.png" +``` + +多图参考: + +```bash +curl http://localhost:3000/v1/images/edits \ + -H "Authorization: Bearer " \ + -F "model=gpt-image-2" \ + -F "prompt=融合两张参考图的产品外观,生成一张干净的广告图" \ + -F "image[]=@./reference-a.png" \ + -F "image[]=@./reference-b.png" +``` + +`messages` 如果通过表单传入,必须是 JSON 字符串: + +```bash +-F 'messages=[{"role":"user","content":"参考上一轮风格继续生成"}]' +``` + +## 异步文生图任务 + +### `POST /api/creation-tasks/image-generations` + +请求体格式:`application/json` + +必填字段: + +- `client_task_id`:客户端生成的任务 ID。同一用户重复提交同一个 ID 会返回已有任务,用于幂等。 +- `prompt` + +示例: + +```bash +curl http://localhost:3000/api/creation-tasks/image-generations \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "client_task_id": "img-task-20260511-001", + "model": "gpt-image-2", + "prompt": "一张用于产品发布会的未来城市主视觉", + "n": 2, + "size": "21:9", + "image_resolution": "2k", + "output_format": "webp", + "visibility": "private" + }' +``` + +提交成功响应示例: + +```json +{ + "id": "img-task-20260511-001", + "status": "queued", + "mode": "generate", + "model": "gpt-image-2", + "size": "21:9", + "created_at": "2026-05-11 13:44:41", + "updated_at": "2026-05-11 13:44:41", + "output_format": "webp", + "output_statuses": ["queued", "queued"], + "visibility": "private" +} +``` + +任务提交后后台异步执行。调用方需要使用查询接口轮询任务状态。 + +## 异步图生图任务 + +### `POST /api/creation-tasks/image-edits` + +请求体格式:`multipart/form-data` + +必填字段: + +- `client_task_id` +- `prompt` +- `image` 或 `image[]` + +示例: + +```bash +curl http://localhost:3000/api/creation-tasks/image-edits \ + -H "Authorization: Bearer " \ + -F "client_task_id=edit-task-20260511-001" \ + -F "model=auto" \ + -F "prompt=保留人物姿态,改成电影海报质感" \ + -F "n=1" \ + -F "size=3:4" \ + -F "image_resolution=1080p" \ + -F "output_format=png" \ + -F "visibility=public" \ + -F "image=@./portrait.png" +``` + +带遮罩示例: + +```bash +curl http://localhost:3000/api/creation-tasks/image-edits \ + -H "Authorization: Bearer " \ + -F "client_task_id=edit-task-mask-001" \ + -F "prompt=只替换背景为雪山,主体不变" \ + -F "image=@./input.png" \ + -F "input_image_mask=data:image/png;base64," +``` + +## 查询任务 + +### `GET /api/creation-tasks` + +查询当前用户的任务列表: + +```bash +curl "http://localhost:3000/api/creation-tasks" \ + -H "Authorization: Bearer " +``` + +按任务 ID 查询: + +```bash +curl "http://localhost:3000/api/creation-tasks?ids=img-task-20260511-001,edit-task-20260511-001" \ + -H "Authorization: Bearer " +``` + +响应示例: + +```json +{ + "items": [ + { + "id": "img-task-20260511-001", + "status": "success", + "mode": "generate", + "model": "gpt-image-2", + "size": "21:9", + "created_at": "2026-05-11 13:44:41", + "updated_at": "2026-05-11 13:45:12", + "output_format": "webp", + "output_statuses": ["success", "success"], + "visibility": "private", + "data": [ + { + "url": "http://localhost:3000/images/2026/05/11/example-1.webp", + "revised_prompt": "一张用于产品发布会的未来城市主视觉", + "output_format": "webp" + }, + { + "url": "http://localhost:3000/images/2026/05/11/example-2.webp", + "revised_prompt": "一张用于产品发布会的未来城市主视觉", + "output_format": "webp" + } + ] + } + ], + "missing_ids": [] +} +``` + +## 取消任务 + +### `POST /api/creation-tasks/{id}/cancel` + +示例: + +```bash +curl http://localhost:3000/api/creation-tasks/img-task-20260511-001/cancel \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{}' +``` + +如果任务仍处于 `queued` 或 `running`,服务会标记为 `cancelled` 并尝试取消后台执行。已完成任务重复取消会返回当前任务状态。 + +## 任务状态 + +任务级 `status`: + +| 状态 | 含义 | +| --- | --- | +| `queued` | 已入队,等待执行。 | +| `running` | 正在执行。 | +| `success` | 已成功完成。 | +| `error` | 执行失败。错误文本在 `error` 字段。 | +| `cancelled` | 已取消。 | + +图片输出级 `output_statuses`: + +| 状态 | 含义 | +| --- | --- | +| `queued` | 单张输出等待开始。 | +| `running` | 单张输出正在生成。 | +| `success` | 单张输出已产出图片或文本结果。 | +| `error` | 单张输出失败,或生成成功但本地余额/配额扣减失败因此未交付。 | +| `cancelled` | 单张输出随任务终止。 | + +`output_statuses` 的长度通常与 `n` 一致,适合 Web 端逐张展示占位和进度。 + +## 响应字段 + +任务对象常见字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 客户端提交的 `client_task_id`。 | +| `status` | string | 任务状态。 | +| `mode` | string | `generate`、`edit` 或 `chat`。本文档关注 `generate` 和 `edit`。 | +| `model` | string | 实际记录的模型。 | +| `size` | string | 请求尺寸或比例。 | +| `quality` | string | 请求质量,未传时可能省略。 | +| `output_format` | string | 归一化后的输出格式。 | +| `output_compression` | number | JPEG 压缩率,仅 JPEG 时可能出现。 | +| `background` | string | 背景参数,传入时可能出现。 | +| `moderation` | string | 审核参数,传入时可能出现。 | +| `style` | string | 风格参数,传入时可能出现。 | +| `partial_images` | number | 部分图片参数,传入正整数时可能出现。 | +| `input_image_mask` | string | 编辑遮罩参数,传入时可能出现。 | +| `output_statuses` | string[] | 单张输出状态。 | +| `data` | array | 输出结果数组。成功后出现。 | +| `error` | string | 失败原因。失败或取消时可能出现。 | +| `output_type` | string | 文本型结果时为 `text`。 | +| `visibility` | string | `private` 或 `public`。 | +| `created_at` | string | 本地时间字符串。 | +| `updated_at` | string | 本地时间字符串。 | + +图片结果项常见字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `url` | string | 服务保存后的图片 URL。 | +| `b64_json` | string | base64 图片。同步接口且 `response_format=b64_json` 时返回。 | +| `revised_prompt` | string | 上游或服务记录的最终提示词。 | +| `output_format` | string | 输出格式。 | + +图片可见性和 JPEG 压缩率当前主要在任务级字段返回。调用方展示图库状态时优先读取任务级 `visibility`;保存后的图片元数据由服务端图库模块维护。 + +文本结果项: + +```json +{ + "output_type": "text", + "data": [ + { + "text_response": "你好!我是 ChatGPT。" + } + ] +} +``` + +## 文本型结果说明 + +图片接口调用上游后,可能得到文本回复而不是图片。例如用户输入“你好,你是什么模型?”时,上游可能按聊天问题回答而不是调用图片工具。 + +当前处理方式: + +- 同步 `/v1/images/generations` 和 `/v1/images/edits`:返回 OpenAI 风格错误,`code` 为 `image_generation_text_response`,HTTP 状态通常为 `400`。 +- 异步 `/api/creation-tasks/image-generations` 和 `/api/creation-tasks/image-edits`:任务会被标记为 `success`,同时返回 `output_type=text` 和 `data[].text_response`,避免 Web 端只显示泛化的失败提示。 + +调用方如果只接受图片,需要在任务成功后检查: + +- `task.output_type !== "text"` +- `task.data[]` 中存在 `url` 或 `b64_json` + +## 错误格式 + +认证失败: + +```json +{ + "detail": { + "error": "authorization is invalid" + } +} +``` + +普通参数错误: + +```json +{ + "detail": { + "error": "prompt is required" + } +} +``` + +OpenAI 风格图片错误: + +```json +{ + "error": { + "message": "Image generation returned a text response instead of image data.", + "type": "invalid_request_error", + "param": null, + "code": "image_generation_text_response" + } +} +``` + +图片额度不足: + +```json +{ + "error": { + "message": "no available image quota", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} +``` + +常见错误: + +| HTTP 状态 | 场景 | 错误文本或 code | +| --- | --- | --- | +| `400` | JSON 解析失败 | `invalid json body` | +| `400` | 缺少提示词 | `prompt is required` | +| `400` | 异步任务缺少 ID | `client_task_id is required` | +| `400` | `n` 超出范围 | `n must be between 1 and 4` | +| `400` | 图生图缺少图片 | `image file is required` 或 `image is required` | +| `400` | `messages` 表单字段不是 JSON | `invalid messages` | +| `400` | 非法可见性 | `visibility must be private or public` | +| `400` | 上游返回文本而非图片 | `image_generation_text_response` | +| `401` | 未认证或 token 无效 | `authorization is invalid` | +| `403` | 权限不足 | `permission denied` | +| `429` | 图片额度或任务并发限制 | `insufficient_quota` 或任务限制错误文本 | +| `502` | 上游或协议失败 | `upstream_error`、上游返回的错误详情,或无图片输出时的诊断消息 | + +## 上下文边界 + +当前图片生成接口默认是无状态的: + +- 每次 `/v1/images/generations` 请求只应依赖本次请求体。 +- 每个 `/api/creation-tasks/image-generations` 任务只应依赖本次任务 payload。 +- `messages` 字段会被接收和透传,但当前不保证它等价于 ChatGPT Web 端“对话作画记忆”。 +- `visibility`、任务历史、图库记录只用于本地管理,不会自动变成下一次官方图片链路的上下文。 + +如果未来需要 Web 端对话作画上下文,应使用显式的上下文拼装策略,并按用户、API key、会话隔离。后续扩展设计见 [图片对话上下文设计](image-conversation-context-design.md)。 + +## 推荐调用流程 + +Web 端推荐使用异步任务接口: + +1. 前端生成唯一 `client_task_id`。 +2. 调用 `/api/creation-tasks/image-generations` 或 `/api/creation-tasks/image-edits` 提交任务。 +3. 使用 `GET /api/creation-tasks?ids=` 轮询。 +4. 当 `status=success` 且 `output_type` 不是 `text` 时展示 `data[].url`。 +5. 当 `status=success` 且 `output_type=text` 时展示 `data[].text_response` 或提示用户改用明确的绘图提示词。 +6. 当 `status=error` 或 `cancelled` 时展示 `error`。 + +外部兼容客户端推荐使用同步接口: + +1. 调用 `/v1/images/generations` 或 `/v1/images/edits`。 +2. 按 OpenAI 图片响应读取 `data[]`。 +3. 对 `error.code=image_generation_text_response` 做单独提示,说明当前提示词没有触发图片输出。 diff --git a/internal/backend/backend.go b/internal/backend/backend.go index b6cb935fd..3b9d360f1 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -147,6 +147,19 @@ func (c *Client) StreamConversation(ctx context.Context, messages []map[string]a errCh <- err return } + if c.AccessToken != "" { + conduitToken, prepareErr := c.prepareTextConversation(ctx, messages, reqs, model) + if prepareErr == nil { + resp, startErr := c.startTextConversation(ctx, messages, reqs, conduitToken, model) + if startErr == nil { + defer resp.Body.Close() + if ensureOK(resp, officialStreamPath) == nil { + errCh <- iterSSEPayloads(ctx, resp.Body, out) + return + } + } + } + } path, timezoneName := c.chatTarget() payload := c.conversationPayload(messages, model, timezoneName) resp, err := c.postJSON(ctx, path, payload, c.conversationHeaders(path, reqs), true) @@ -430,6 +443,284 @@ func (c *Client) chatTarget() (string, string) { return "/backend-anon/conversation", "America/Los_Angeles" } +func textModelSlug(model string) string { + switch strings.TrimSpace(model) { + case "auto", "": + return "auto" + default: + return strings.TrimSpace(model) + } +} + +func (c *Client) prepareTextConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, model string) (string, error) { + prompt := conversationPrompt(messages) + payload := map[string]any{ + "action": "next", + "fork_from_shared_post": false, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "success", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "system_hints": []any{}, + "partial_query": map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "content": map[string]any{"content_type": "text", "parts": []any{prompt}}, + }, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "client_contextual_info": map[string]any{ + "app_name": "chatgpt.com", + }, + } + resp, err := c.postJSON(ctx, officialPreparePath, payload, c.officialHeaders(officialPreparePath, reqs, "", "*/*"), false) + if err != nil { + return "", err + } + defer resp.Body.Close() + if err := ensureOK(resp, officialPreparePath); err != nil { + return "", err + } + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + return util.Clean(data["conduit_token"]), nil +} + +func (c *Client) startTextConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, conduitToken, model string) (*http.Response, error) { + prompt := conversationPrompt(messages) + payload := map[string]any{ + "action": "next", + "messages": []any{ + map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "create_time": float64(time.Now().UnixNano()) / 1e9, + "content": map[string]any{ + "content_type": "text", + "parts": []any{prompt}, + }, + "metadata": map[string]any{ + "developer_mode_connector_ids": []any{}, + "selected_github_repos": []any{}, + "selected_all_github_repos": false, + "serialization_metadata": map[string]any{"custom_symbol_offsets": []any{}}, + }, + }, + }, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "sent", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "enable_message_followups": true, + "system_hints": []any{}, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + "client_contextual_info": map[string]any{ + "is_dark_mode": false, + "time_since_loaded": 1200, + "page_height": 1072, + "page_width": 1724, + "pixel_ratio": 1.2, + "screen_height": 1440, + "screen_width": 2560, + "app_name": "chatgpt.com", + }, + } + return c.postJSON(ctx, officialStreamPath, payload, c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream"), true) +} + +// VisionImage represents an image to be uploaded for multimodal vision understanding. +type VisionImage struct { + Data []byte + ContentType string + FileName string +} + +func (c *Client) uploadVisionImages(ctx context.Context, images []VisionImage) ([]uploadedImageRef, error) { + refs := make([]uploadedImageRef, 0, len(images)) + for i, img := range images { + fileName := img.FileName + if fileName == "" { + fileName = fmt.Sprintf("image_%d.png", i) + } + ref, err := c.uploadImage(ctx, ResponsesInputImage{Data: img.Data, ContentType: img.ContentType}, fileName) + if err != nil { + return nil, err + } + refs = append(refs, ref) + } + return refs, nil +} + +func buildVisionParts(prompt string, refs []uploadedImageRef) []any { + parts := []any{prompt} + for _, ref := range refs { + parts = append(parts, map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "file-service://" + ref.FileID, + "width": ref.Width, + "height": ref.Height, + "size_bytes": ref.FileSize, + }) + } + return parts +} + +func buildVisionAttachments(refs []uploadedImageRef) []map[string]any { + attachments := make([]map[string]any, 0, len(refs)) + for _, ref := range refs { + attachments = append(attachments, map[string]any{ + "id": ref.FileID, + "mimeType": ref.MIMEType, + "name": ref.FileName, + "size": ref.FileSize, + "width": ref.Width, + "height": ref.Height, + }) + } + return attachments +} + +func (c *Client) prepareMultimodalConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, model string, refs []uploadedImageRef) (string, error) { + prompt := conversationPrompt(messages) + payload := map[string]any{ + "action": "next", + "fork_from_shared_post": false, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "success", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "system_hints": []any{}, + "partial_query": map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "content": map[string]any{"content_type": "multimodal_text", "parts": buildVisionParts(prompt, refs)}, + }, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "client_contextual_info": map[string]any{ + "app_name": "chatgpt.com", + }, + } + resp, err := c.postJSON(ctx, officialPreparePath, payload, c.officialHeaders(officialPreparePath, reqs, "", "*/*"), false) + if err != nil { + return "", err + } + defer resp.Body.Close() + if err := ensureOK(resp, officialPreparePath); err != nil { + return "", err + } + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + return util.Clean(data["conduit_token"]), nil +} + +func (c *Client) startMultimodalConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, conduitToken, model string, refs []uploadedImageRef) (*http.Response, error) { + prompt := conversationPrompt(messages) + attachments := buildVisionAttachments(refs) + payload := map[string]any{ + "action": "next", + "messages": []any{ + map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "create_time": float64(time.Now().UnixNano()) / 1e9, + "content": map[string]any{ + "content_type": "multimodal_text", + "parts": buildVisionParts(prompt, refs), + }, + "metadata": map[string]any{ + "developer_mode_connector_ids": []any{}, + "selected_github_repos": []any{}, + "selected_all_github_repos": false, + "serialization_metadata": map[string]any{"custom_symbol_offsets": []any{}}, + "attachments": attachments, + }, + }, + }, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "sent", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "enable_message_followups": true, + "system_hints": []any{}, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + "force_use_sse": true, + "client_contextual_info": map[string]any{ + "is_dark_mode": false, + "time_since_loaded": 1200, + "page_height": 1072, + "page_width": 1724, + "pixel_ratio": 1.2, + "screen_height": 1440, + "screen_width": 2560, + "app_name": "chatgpt.com", + }, + } + return c.postJSON(ctx, officialStreamPath, payload, c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream"), true) +} + +func (c *Client) StreamMultimodalConversation(ctx context.Context, messages []map[string]any, model string, images []VisionImage) (<-chan string, <-chan error) { + out := make(chan string) + errCh := make(chan error, 1) + go func() { + defer close(out) + defer close(errCh) + if c.AccessToken == "" { + errCh <- fmt.Errorf("vision requires authentication") + return + } + if err := c.bootstrap(ctx); err != nil { + errCh <- err + return + } + reqs, err := c.getChatRequirements(ctx) + if err != nil { + errCh <- err + return + } + refs, err := c.uploadVisionImages(ctx, images) + if err != nil { + errCh <- err + return + } + conduitToken, err := c.prepareMultimodalConversation(ctx, messages, reqs, model, refs) + if err != nil { + errCh <- err + return + } + resp, err := c.startMultimodalConversation(ctx, messages, reqs, conduitToken, model, refs) + if err != nil { + errCh <- err + return + } + defer resp.Body.Close() + if err := ensureOK(resp, officialStreamPath); err != nil { + errCh <- err + return + } + errCh <- iterMultimodalSSEPayloads(ctx, resp.Body, out) + }() + return out, errCh +} + func (c *Client) conversationPayload(messages []map[string]any, model, timezoneName string) map[string]any { conversationMessages := []map[string]any{conversationUserMessage(conversationPrompt(messages))} return map[string]any{ @@ -647,9 +938,85 @@ func iterSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) e } } if err == io.EOF { + if len(buf) > 0 { + line := strings.TrimSpace(string(buf)) + if strings.HasPrefix(line, "data:") { + payload := strings.TrimSpace(line[5:]) + if payload != "" { + select { + case out <- payload: + case <-ctx.Done(): + return ctx.Err() + } + } + } + } + return nil + } + if err != nil { + return err + } + } +} + +func iterMultimodalSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) error { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 2048) + processLine := func(line string) error { + if !strings.HasPrefix(line, "data:") { + return nil + } + payload := strings.TrimSpace(line[5:]) + if payload == "" || payload == "[DONE]" { + return nil + } + var event map[string]any + if json.Unmarshal([]byte(payload), &event) != nil { + return nil + } + if isComplete, _ := event["is_complete"].(bool); isComplete { + return nil + } + for _, text := range extractMultimodalText(event) { + select { + case out <- text: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + } + + for { + n, err := reader.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + for { + idx := bytes.IndexByte(buf, '\n') + if idx < 0 { + break + } + line := strings.TrimSpace(string(buf[:idx])) + buf = buf[idx+1:] + if err := processLine(line); err != nil { + return err + } + } + } + if err == io.EOF { + if len(buf) > 0 { + line := strings.TrimSpace(string(buf)) + if err := processLine(line); err != nil { + return err + } + } return nil } if err != nil { + if len(buf) > 0 { + line := strings.TrimSpace(string(buf)) + _ = processLine(line) + } return err } } @@ -663,3 +1030,58 @@ func firstNonEmpty(values ...string) string { } return "" } + +func extractMultimodalText(event map[string]any) []string { + if v, ok := event["v"]; ok { + switch val := v.(type) { + case string: + if val != "" { + return []string{val} + } + case []any: + var texts []string + for _, item := range val { + if op, ok := item.(map[string]any); ok { + if op["o"] == "append" { + if s, ok := op["v"].(string); ok && strings.TrimSpace(s) != "" { + texts = append(texts, s) + } + } + } + } + if len(texts) > 0 { + return texts + } + case map[string]any: + if texts := extractPartsText(val); len(texts) > 0 { + return texts + } + } + } + if event["o"] == "append" { + if s, ok := event["v"].(string); ok && strings.TrimSpace(s) != "" { + return []string{s} + } + } + if msg, ok := event["message"].(map[string]any); ok { + if texts := extractPartsText(msg); len(texts) > 0 { + return texts + } + } + return nil +} + +func extractPartsText(message map[string]any) []string { + content, _ := message["content"].(map[string]any) + if content == nil { + return nil + } + parts, _ := content["parts"].([]any) + var texts []string + for _, part := range parts { + if text, ok := part.(string); ok && text != "" { + texts = append(texts, text) + } + } + return texts +} diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index ae9a6b029..e69169c60 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -4,16 +4,43 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "time" ) func ptrInt(value int) *int { return &value } +func newTestBackendClient(server *httptest.Server) *Client { + client := &Client{ + BaseURL: server.URL, + AccessToken: "token-1", + httpClient: server.Client(), + lookup: testAccountLookup{ + "token-1": {"chatgpt_account_id": "acct-1"}, + }, + } + client.fp = client.buildFingerprint() + client.applyBrowserFingerprint() + client.userAgent = client.fp["user-agent"] + client.deviceID = client.fp["oai-device-id"] + client.sessionID = client.fp["oai-session-id"] + return client +} + +func setOfficialImageDownloadRetryDelayForTest(delay time.Duration) func() { + previous := officialImageDownloadRetryDelay + officialImageDownloadRetryDelay = delay + return func() { + officialImageDownloadRetryDelay = previous + } +} + func TestUpstreamHTTPErrorSummarizesCloudflareChallenge(t *testing.T) { err := upstreamHTTPError("bootstrap", 403, []byte(`Enable JavaScript and cookies to continue`)) got := err.Error() @@ -97,7 +124,7 @@ func TestOfficialImageHeadersIncludeSentinelAndConduitTokens(t *testing.T) { "sec-ch-ua-platform-version": browserSecCHUAPlatformVersion, }, } - headers := client.officialImageHeaders(officialImageStreamPath, ChatRequirements{ + headers := client.officialHeaders(officialStreamPath, ChatRequirements{ Token: "req-token", ProofToken: "proof-token", TurnstileToken: "turn-token", @@ -112,7 +139,7 @@ func TestOfficialImageHeadersIncludeSentinelAndConduitTokens(t *testing.T) { "X-Conduit-Token": "conduit-token", "Accept": "text/event-stream", "Content-Type": "application/json", - "X-OpenAI-Target-Path": officialImageStreamPath, + "X-OpenAI-Target-Path": officialStreamPath, } { if got := headers[key]; got != want { t.Fatalf("headers[%s] = %q, want %q", key, got, want) @@ -171,7 +198,7 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: if err := json.NewDecoder(r.Body).Decode(&prepareBody); err != nil { t.Fatalf("decode prepare body: %v", err) } @@ -180,7 +207,7 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: if err := json.NewDecoder(r.Body).Decode(&streamBody); err != nil { t.Fatalf("decode stream body: %v", err) } @@ -192,8 +219,14 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-1\"}\n\n")) case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-1": w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{"async_task_type":"image_gen"},"content":{"content_type":"multimodal_text","parts":[{"asset_pointer":"file-service://file_abc"}]}}}}}`)) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/file_abc/download": + _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{},"content":{"content_type":"multimodal_text","parts":[{"content_type":"image_asset_pointer","asset_pointer":"file-service://file_abc"}]}}}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_abc": + if got := r.URL.Query().Get("conversation_id"); got != "conv-1" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_abc.png"}`)) case r.Method == http.MethodGet && r.URL.Path == "/download/file_abc.png": @@ -274,14 +307,16 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing } } -func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *testing.T) { +func TestStreamResponsesImageUsesOfficialContinuationPointers(t *testing.T) { const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" imageBytes, err := base64.StdEncoding.DecodeString(png1x1) if err != nil { t.Fatalf("decode png: %v", err) } + var prepareBody map[string]any + var streamBody map[string]any + var streamHeaders http.Header var server *httptest.Server - pollCount := 0 server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/": @@ -290,63 +325,1058 @@ func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *tes case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + if err := json.NewDecoder(r.Body).Decode(&prepareBody); err != nil { + t.Fatalf("decode prepare body: %v", err) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + streamHeaders = r.Header.Clone() + if err := json.NewDecoder(r.Body).Decode(&streamBody); err != nil { + t.Fatalf("decode stream body: %v", err) + } w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"type\":\"title_generation\",\"title\":\"正在处理图片\",\"conversation_id\":\"conv-queued\"}\n\n")) - _, _ = w.Write([]byte("data: {\"v\":{\"message\":{\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\"正在处理图片 目前有很多人在创建图片,因此可能需要一点时间。图片准备好后我们会通知你。\"]}}},\"conversation_id\":\"conv-queued\"}\n\n")) - _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-queued\"}\n\n")) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-queued": - pollCount++ + _, _ = w.Write([]byte("data: {\"v\":{\"message\":{\"id\":\"msg-assist-new\",\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\"好的\"]}}},\"conversation_id\":\"conv-old\"}\n\n")) + _, _ = w.Write([]byte("data: {\"p\":\"\",\"o\":\"add\",\"v\":{\"message\":{\"author\":{\"role\":\"tool\",\"metadata\":{}},\"content\":{\"content_type\":\"multimodal_text\",\"parts\":[{\"content_type\":\"image_asset_pointer\",\"asset_pointer\":\"sediment://file_follow\"}]}},\"conversation_id\":\"conv-old\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-old\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_follow": + if got := r.URL.Query().Get("conversation_id"); got != "conv-old" { + t.Fatalf("conversation_id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_follow.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file_follow.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "改成工笔画风格", + Model: "gpt-image-2", + ConversationID: "conv-old", + ParentMessageID: "msg-assist-old", + }) + var results []ResponsesImageEvent + for event := range events { + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if len(results) != 1 || results[0].ConversationID != "conv-old" || results[0].MessageID != "msg-assist-new" { + t.Fatalf("results = %#v", results) + } + if got := prepareBody["conversation_id"]; got != "conv-old" { + t.Fatalf("prepare conversation_id = %#v, want conv-old", got) + } + if got := prepareBody["parent_message_id"]; got != "msg-assist-old" { + t.Fatalf("prepare parent_message_id = %#v, want msg-assist-old", got) + } + if got := streamBody["conversation_id"]; got != "conv-old" { + t.Fatalf("stream conversation_id = %#v, want conv-old", got) + } + if got := streamBody["parent_message_id"]; got != "msg-assist-old" { + t.Fatalf("stream parent_message_id = %#v, want msg-assist-old", got) + } + messages := streamBody["messages"].([]any) + message := messages[0].(map[string]any) + if got := streamHeaders.Get("OpenAI-Conversation-Id"); got != "conv-old" { + t.Fatalf("OpenAI-Conversation-Id = %q, want conv-old", got) + } + if got := streamHeaders.Get("OpenAI-Message-Id"); got == "" || got != message["id"] { + t.Fatalf("OpenAI-Message-Id = %q, message id = %#v", got, message["id"]) + } +} + +func TestOfficialImageAssistantMessageIDExtraction(t *testing.T) { + for name, payload := range map[string]string{ + "message id": `{"message":{"id":"msg-1","author":{"role":"assistant"}}}`, + "message_id": `{"message_id":"msg-2"}`, + "v message id": `{"v":{"message":{"id":"msg-3","author":{"role":"assistant"}}}}`, + } { + t.Run(name, func(t *testing.T) { + state := &imageConversationState{} + event, ok, err := parseOfficialImagePayload(payload, state) + if err != nil || !ok { + t.Fatalf("parseOfficialImagePayload() event=%#v ok=%v err=%v", event, ok, err) + } + if event.MessageID == "" { + t.Fatalf("MessageID missing for %s", payload) + } + }) + } +} + +func TestStreamResponsesImageUsesCodeInterpreterAssetDownload(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + interpreterDownloadCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{"async_task_type":"image_gen"},"content":{"content_type":"multimodal_text","parts":[{"asset_pointer":"file-service://file_ready"}]}}}}}`)) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/file_ready/download": + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_ready.png"}`)) - case r.Method == http.MethodGet && r.URL.Path == "/download/file_ready.png": + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"p\":\"\",\"o\":\"add\",\"v\":{\"message\":{\"author\":{\"role\":\"tool\",\"metadata\":{}},\"content\":{\"content_type\":\"multimodal_text\",\"parts\":[{\"content_type\":\"image_asset_pointer\",\"asset_pointer\":\"file-service://file_old\"}]}},\"conversation_id\":\"conv-ci\"}}\n\n")) + _, _ = w.Write([]byte(`data: {"message":{"id":"msg-current","author":{"role":"assistant"},"content":{"content_type":"multimodal_text","parts":[null],"assets":[{"asset_id":"file_current","file_name":"output.png","file_size":245832,"mime_type":"image/png","width":1024,"height":1024,"metadata":{"generation_type":"interpreter"}}]},"status":"in_progress","metadata":{"async_task_type":"code_interpreter","tool":"code_interpreter","attachments":[]}},"conversation_id":"conv-ci"}` + "\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-ci\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-ci/interpreter/download": + interpreterDownloadCount++ + if got := r.URL.Query().Get("asset_id"); got != "file_current" { + t.Fatalf("asset_id = %q, want file_current", got) + } + if got := r.URL.Query().Get("message_id"); got != "msg-current" { + t.Fatalf("message_id = %q, want msg-current", got) + } w.Header().Set("Content-Type", "image/png") _, _ = w.Write(imageBytes) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-ci": + t.Fatalf("unexpected conversation poll for direct interpreter asset") + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/backend-api/files/download/"): + t.Fatalf("unexpected file download for interpreter asset: %s", r.URL.Path) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } })) defer server.Close() - client := &Client{ - BaseURL: server.URL, - AccessToken: "token-1", - httpClient: server.Client(), - lookup: testAccountLookup{ - "token-1": {"chatgpt_account_id": "acct-1"}, + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "改成彩色漫画", + Model: "gpt-image-2", + ConversationID: "conv-ci", + ParentMessageID: "msg-previous", + }) + var results []ResponsesImageEvent + for event := range events { + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if interpreterDownloadCount != 1 { + t.Fatalf("interpreter download count = %d, want 1", interpreterDownloadCount) + } + if len(results) != 1 || results[0].Result != png1x1 || results[0].MessageID != "msg-current" || results[0].ConversationID != "conv-ci" { + t.Fatalf("results = %#v", results) + } +} + +func TestStreamResponsesImageIgnoresHistoricalInterpreterAssets(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"v":{"message":{"id":"msg-old","author":{"role":"assistant"},"content":{"content_type":"multimodal_text","parts":[null],"assets":[{"asset_id":"file_old","mime_type":"image/png"}]},"metadata":{"async_task_type":"code_interpreter","tool":"code_interpreter"}},"conversation_id":"conv-ci"}}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"message":{"id":"msg-current","author":{"role":"assistant"},"content":{"content_type":"multimodal_text","parts":[null],"assets":[{"asset_id":"file_current","file_name":"output.png","mime_type":"image/png","width":1024,"height":1024}]},"metadata":{"async_task_type":"code_interpreter","tool":"code_interpreter"}},"conversation_id":"conv-ci"}` + "\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-ci\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-ci/interpreter/download": + if got := r.URL.Query().Get("asset_id"); got != "file_current" { + t.Fatalf("asset_id = %q, want file_current", got) + } + if got := r.URL.Query().Get("message_id"); got != "msg-current" { + t.Fatalf("message_id = %q, want msg-current", got) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{Prompt: "改成彩色漫画", Model: "gpt-image-2"}) + var results []ResponsesImageEvent + for event := range events { + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if len(results) != 1 || results[0].Result != png1x1 || results[0].MessageID != "msg-current" { + t.Fatalf("results = %#v", results) + } +} + +func TestOfficialConversationPollResultUsesLatestImageToolMessage(t *testing.T) { + data := map[string]any{ + "mapping": map[string]any{ + "old-tool": map[string]any{"message": map[string]any{ + "author": map[string]any{"role": "tool"}, + "create_time": float64(1), + "metadata": map[string]any{"async_task_type": "image_gen"}, + "content": map[string]any{ + "content_type": "multimodal_text", + "parts": []any{map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "file-service://file_old", + }}, + }, + }}, + "new-tool": map[string]any{"message": map[string]any{ + "author": map[string]any{"role": "tool"}, + "create_time": float64(2), + "metadata": map[string]any{"async_task_type": "image_gen"}, + "content": map[string]any{ + "content_type": "multimodal_text", + "parts": []any{map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "file-service://file_new sediment://sediment_new", + }}, + }, + }}, + "assistant": map[string]any{"message": map[string]any{ + "id": "msg-new", + "author": map[string]any{"role": "assistant"}, + "create_time": float64(3), + "recipient": "all", + "content": map[string]any{"content_type": "text", "parts": []any{"完成"}}, + }}, }, } - client.fp = client.buildFingerprint() - client.applyBrowserFingerprint() - client.userAgent = client.fp["user-agent"] - client.deviceID = client.fp["oai-device-id"] - client.sessionID = client.fp["oai-session-id"] + result := officialConversationPollResultFromData(data) + if got := strings.Join(result.FileIDs, ","); got != "file_new" { + t.Fatalf("FileIDs = %q, want file_new", got) + } + if got := strings.Join(result.SedimentIDs, ","); got != "sediment_new" { + t.Fatalf("SedimentIDs = %q, want sediment_new", got) + } + if result.MessageID != "msg-new" { + t.Fatalf("MessageID = %q, want msg-new", result.MessageID) + } + if result.Text != "" { + t.Fatalf("Text = %q, want empty when image asset exists", result.Text) + } +} + +func TestOfficialConversationPollResultWithTargetIgnoresHistoricalImage(t *testing.T) { + data := map[string]any{ + "mapping": map[string]any{ + "old-tool": map[string]any{"message": map[string]any{ + "id": "msg-old-tool", + "author": map[string]any{"role": "tool"}, + "create_time": float64(20), + "metadata": map[string]any{"async_task_type": "image_gen", "turn_exchange_id": "turn-old"}, + "content": map[string]any{ + "content_type": "multimodal_text", + "parts": []any{map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "file-service://file_old", + }}, + }, + }}, + "assistant-current": map[string]any{"message": map[string]any{ + "id": "msg-current-assistant", + "author": map[string]any{"role": "assistant"}, + "create_time": float64(30), + "metadata": map[string]any{"turn_exchange_id": "turn-current"}, + "content": map[string]any{"content_type": "code", "text": "{\"skipped_mainline\":true}"}, + }}, + "current-tool": map[string]any{ + "parent": "assistant-current", + "message": map[string]any{ + "id": "msg-current-tool", + "author": map[string]any{"role": "tool"}, + "create_time": float64(10), + "metadata": map[string]any{"async_task_type": "image_gen", "parent_id": "msg-current-assistant"}, + "content": map[string]any{ + "content_type": "multimodal_text", + "parts": []any{map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "file-service://file_current", + }}, + }, + }, + }, + }, + } + + result := officialConversationPollResultFromDataForTarget(data, officialImagePollTarget{TurnExchangeID: "turn-current", MessageIDs: []string{"msg-current-assistant"}}) + if got := strings.Join(result.FileIDs, ","); got != "file_current" { + t.Fatalf("FileIDs = %q, want file_current", got) + } + + waiting := officialConversationPollResultFromDataForTarget(data, officialImagePollTarget{TurnExchangeID: "turn-missing", MessageIDs: []string{"msg-missing"}}) + if len(waiting.FileIDs) != 0 || len(waiting.SedimentIDs) != 0 || waiting.Text != "" { + t.Fatalf("missing target result = %#v, want empty while waiting", waiting) + } +} + +func TestStreamResponsesImageUsesDirectSSEImageAssetPointer(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + var server *httptest.Server + pollCount := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"p\":\"\",\"o\":\"add\",\"v\":{\"message\":{\"author\":{\"role\":\"tool\",\"metadata\":{}},\"content\":{\"content_type\":\"multimodal_text\",\"parts\":[{\"content_type\":\"image_asset_pointer\",\"asset_pointer\":\"sediment://file_direct\"}]}},\"conversation_id\":\"conv-direct\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-direct\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-direct": + pollCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_direct": + if got := r.URL.Query().Get("conversation_id"); got != "conv-direct" { + t.Fatalf("conversation_id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_direct.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file_direct.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ Prompt: "生成封面", Model: "gpt-image-2", }) var results []ResponsesImageEvent - var texts []string for event := range events { - if strings.TrimSpace(event.Text) != "" { - texts = append(texts, event.Text) + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if pollCount != 0 { + t.Fatalf("conversation poll count = %d, want direct SSE asset to avoid polling", pollCount) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one direct image result", results) + } +} + +func TestStreamResponsesImageIgnoresFalseToolInvokedForImageGenResult(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"p\":\"\",\"o\":\"add\",\"v\":{\"message\":{\"author\":{\"role\":\"tool\",\"metadata\":{}},\"content\":{\"content_type\":\"multimodal_text\",\"parts\":[{\"content_type\":\"image_asset_pointer\",\"asset_pointer\":\"sediment://file_image\"}]}},\"conversation_id\":\"conv-image\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"v\":{\"message\":{\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\"Here is the generated image.\"]}}},\"conversation_id\":\"conv-image\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"server_ste_metadata\",\"metadata\":{\"tool_invoked\":false,\"turn_use_case\":\"image gen\"},\"conversation_id\":\"conv-image\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-image\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_image": + if got := r.URL.Query().Get("conversation_id"); got != "conv-image" { + t.Fatalf("conversation_id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_image.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file_image.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + Model: "gpt-image-2", + }) + var results []ResponsesImageEvent + var textResponses []ResponsesImageEvent + for event := range events { if event.Result != "" { results = append(results, event) } + if event.Type == "image_text_response" { + textResponses = append(textResponses, event) + } } if err := <-errCh; err != nil { t.Fatalf("StreamResponsesImage() error = %v", err) } - if pollCount == 0 { - t.Fatal("expected conversation polling after queued assistant notice") + if len(textResponses) != 0 { + t.Fatalf("text responses = %#v, want none for successful image gen", textResponses) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one image result", results) + } +} + +func TestOfficialImageEditNoResultWaitsForCallerContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-empty" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{}}`)) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + client := newTestBackendClient(server) + out := make(chan ResponsesImageEvent, 8) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + err := iterOfficialImageSSE( + ctx, + client, + strings.NewReader("data: {\"type\":\"resume_conversation_token\",\"conversation_id\":\"conv-empty\"}\n\ndata: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-empty\"}\n\n"), + ResponsesImageRequest{ + Prompt: "修改参考图", + Model: "gpt-image-2", + InputImages: []ResponsesInputImage{{Data: []byte("image"), ContentType: "image/png"}}, + }, + out, + ) + close(out) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("iterOfficialImageSSE() error = %v, want context deadline", err) + } +} + +func TestOfficialImageFinalTextBypassesImageResultPolling(t *testing.T) { + const upstreamText = "上游返回的任何非排队文本都应该原样返回。" + client := &Client{} + out := make(chan ResponsesImageEvent, 8) + err := iterOfficialImageSSE( + context.Background(), + client, + strings.NewReader( + "data: {\"v\":{\"message\":{\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\""+upstreamText+"\"]}}},\"conversation_id\":\"conv-text\"}\n\n"+ + "data: {\"type\":\"server_ste_metadata\",\"metadata\":{\"turn_use_case\":\"image gen\"},\"conversation_id\":\"conv-text\"}\n\n"+ + "data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-text\"}\n\n", + ), + ResponsesImageRequest{Prompt: "修改参考图", Model: "gpt-image-2"}, + out, + ) + close(out) + + if err != nil { + t.Fatalf("iterOfficialImageSSE() error = %v", err) + } + var got ResponsesImageEvent + for event := range out { + if event.Type == "image_text_response" { + got = event + } + } + if got.Text != upstreamText { + t.Fatalf("image_text_response = %#v, want upstream text", got) + } +} + +func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + var server *httptest.Server + pollCount := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"title_generation\",\"title\":\"正在处理图片\",\"conversation_id\":\"conv-queued\"}\n\n")) + _, _ = w.Write([]byte("data: {\"v\":{\"message\":{\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\"正在处理图片 目前有很多人在创建图片,因此可能需要一点时间。图片准备好后我们会通知你。\"]}}},\"conversation_id\":\"conv-queued\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-queued\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-queued": + pollCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{},"content":{"content_type":"multimodal_text","parts":[{"content_type":"image_asset_pointer","asset_pointer":"file-service://file_ready"}]}}}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_ready": + if got := r.URL.Query().Get("conversation_id"); got != "conv-queued" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_ready.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file_ready.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := &Client{ + BaseURL: server.URL, + AccessToken: "token-1", + httpClient: server.Client(), + lookup: testAccountLookup{ + "token-1": {"chatgpt_account_id": "acct-1"}, + }, + } + client.fp = client.buildFingerprint() + client.applyBrowserFingerprint() + client.userAgent = client.fp["user-agent"] + client.deviceID = client.fp["oai-device-id"] + client.sessionID = client.fp["oai-session-id"] + + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + Model: "gpt-image-2", + }) + var results []ResponsesImageEvent + var texts []string + for event := range events { + if strings.TrimSpace(event.Text) != "" { + texts = append(texts, event.Text) + } + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if pollCount == 0 { + t.Fatal("expected conversation polling after queued assistant notice") + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + +func TestStreamResponsesImagePollsAsyncImageTaskForCurrentTurn(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"type":"resume_conversation_token","conversation_id":"conv-async"}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"p":"","o":"add","v":{"message":{"id":"msg-code","author":{"role":"assistant"},"content":{"content_type":"code","language":"python3","text":"{\"skipped_mainline\":true}"},"metadata":{"parent_id":"msg-user","turn_exchange_id":"turn-current"},"recipient":"tool-name"},"conversation_id":"conv-async"}}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"p":"","o":"add","v":{"message":{"id":"msg-card","author":{"role":"tool","name":"tool-name"},"content":{"content_type":"text","parts":["正在处理图片\n\n目前有很多人在创建图片,因此可能需要一点时间。"]},"metadata":{"ui_card":true,"image_gen_task_id":"task-current","parent_id":"msg-code","turn_exchange_id":"turn-current"},"recipient":"all"},"conversation_id":"conv-async"}}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"server_ste_metadata","metadata":{"tool_invoked":false,"message_id":"msg-code","turn_exchange_id":"turn-current","turn_use_case":"image gen"},"conversation_id":"conv-async"}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"message_stream_complete","conversation_id":"conv-async"}` + "\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-async": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{"old-tool":{"message":{"id":"msg-old","author":{"role":"tool"},"create_time":20,"metadata":{"async_task_type":"image_gen","turn_exchange_id":"turn-old"},"content":{"content_type":"multimodal_text","parts":[{"content_type":"image_asset_pointer","asset_pointer":"file-service://file_old"}]}}},"current-tool":{"message":{"id":"msg-current","author":{"role":"tool"},"create_time":10,"metadata":{"async_task_type":"image_gen","image_gen_task_id":"task-current","turn_exchange_id":"turn-current","parent_id":"msg-code"},"content":{"content_type":"multimodal_text","parts":[{"content_type":"image_asset_pointer","asset_pointer":"file-service://file_current"}]}}}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_current": + if got := r.URL.Query().Get("conversation_id"); got != "conv-async" { + t.Fatalf("conversation_id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_current.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_old": + t.Fatalf("downloaded historical image") + case r.Method == http.MethodGet && r.URL.Path == "/download/file_current.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{Prompt: "改成动漫风格", Model: "gpt-image-2"}) + var results []ResponsesImageEvent + for event := range events { + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if len(results) != 1 || results[0].Result != png1x1 || results[0].ConversationID != "conv-async" { + t.Fatalf("results = %#v, want current async image", results) + } +} + +func TestStreamResponsesImageRetriesConversationPollRateLimit(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + var server *httptest.Server + pollCount := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"resume_conversation_token\",\"conversation_id\":\"conv-rate-limited\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-rate-limited\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-rate-limited": + pollCount++ + w.Header().Set("Content-Type", "application/json") + if pollCount == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"detail":"Too many requests"}`)) + return + } + _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{"async_task_type":"image_gen"},"content":{"content_type":"multimodal_text","parts":[{"content_type":"image_asset_pointer","asset_pointer":"file-service://file_ready"}]}}}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_ready": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_ready.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file_ready.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + Model: "gpt-image-2", + }) + var results []ResponsesImageEvent + for event := range events { + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if pollCount != 2 { + t.Fatalf("conversation poll count = %d, want retry after rate limit", pollCount) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + +func TestStreamResponsesImageReturnsPolledConversationText(t *testing.T) { + const finalText = "非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。如果你认为此判断有误,请重试或修改提示语。" + pollCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"title_generation\",\"title\":\"正在处理图片\",\"conversation_id\":\"conv-refused\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-refused\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-refused": + pollCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{ + "assistant-text":{"message":{"author":{"role":"assistant"},"create_time":3,"content":{"content_type":"text","parts":["` + finalText + `"]},"status":"finished_successfully","recipient":"all","metadata":{"model_slug":"gpt-5-5"}}} + }}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "修改参考图", + Model: "gpt-image-2", + }) + var textEvents []ResponsesImageEvent + var results []ResponsesImageEvent + for event := range events { + if event.Type == "image_text_response" { + textEvents = append(textEvents, event) + } + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if pollCount != 1 { + t.Fatalf("conversation poll count = %d, want one poll", pollCount) + } + if len(results) != 0 { + t.Fatalf("results = %#v, want no image results", results) + } + if len(textEvents) != 1 || textEvents[0].Text != finalText { + t.Fatalf("text events = %#v, want upstream conversation text", textEvents) + } +} + +func TestStreamResponsesImageEmitsFinalTextWhenNoImageResult(t *testing.T) { + const finalText = "你好!我是 ChatGPT。" + var server *httptest.Server + pollCount := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"v\":{\"message\":{\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\"" + finalText + "\"]}}},\"conversation_id\":\"conv-text\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-text\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-text": + pollCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "你好,你是什么模型?", + Model: "gpt-image-2", + }) + var texts []string + var results []ResponsesImageEvent + for event := range events { + if strings.TrimSpace(event.Text) != "" { + texts = append(texts, event.Text) + } + if event.Result != "" { + results = append(results, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if len(results) != 0 { + t.Fatalf("results = %#v, want no image results", results) + } + if len(texts) == 0 || texts[len(texts)-1] != finalText { + t.Fatalf("texts = %#v, want final text %q", texts, finalText) + } + if pollCount != 0 { + t.Fatalf("conversation poll count = %d, want no polling for final text", pollCount) + } +} + +func TestStreamResponsesImageFetchesHistoryTextForTextTurn(t *testing.T) { + const finalText = "你好!我是 GPT-5 mini。" + historyCount := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"resume_conversation_token\",\"conversation_id\":\"conv-history\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"server_ste_metadata\",\"conversation_id\":\"conv-history\",\"metadata\":{\"tool_invoked\":false,\"turn_use_case\":\"text\"}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-history\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-history": + historyCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{ + "user-node":{"message":{"author":{"role":"user"},"create_time":1,"content":{"content_type":"text","parts":["你好,你是什么模型?"]},"status":"finished_successfully","recipient":"all","metadata":{}}}, + "assistant-context":{"message":{"author":{"role":"assistant"},"create_time":2,"content":{"content_type":"model_editable_context"},"status":"finished_successfully","recipient":"all","metadata":{"is_visually_hidden_from_conversation":true}}}, + "assistant-text":{"message":{"author":{"role":"assistant"},"create_time":3,"content":{"content_type":"text","parts":["` + finalText + `"]},"status":"finished_successfully","recipient":"all","metadata":{"model_slug":"gpt-5-5"}}} + }}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "你好,你是什么模型?", + Model: "gpt-image-2", + }) + var textEvents []ResponsesImageEvent + for event := range events { + if event.Type == "image_text_response" { + textEvents = append(textEvents, event) + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamResponsesImage() error = %v", err) + } + if len(textEvents) != 1 { + t.Fatalf("text events = %#v, want one text response", textEvents) + } + if textEvents[0].Text != finalText { + t.Fatalf("text response = %q, want %q", textEvents[0].Text, finalText) + } + if historyCount != 1 { + t.Fatalf("history count = %d, want 1", historyCount) + } +} + +func TestResolveOfficialImageResultsRetriesTransientDownloadURL404(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + resetOfficialImageRetryDelay := setOfficialImageDownloadRetryDelayForTest(0) + defer resetOfficialImageRetryDelay() + + downloadAttempts := 0 + urlAttempts := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_img": + urlAttempts++ + if got := r.URL.Query().Get("conversation_id"); got != "conv-1" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/files/download/file_img" { + t.Fatalf("target path = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/image.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/image.png": + downloadAttempts++ + if downloadAttempts < officialImageDownloadAttempts { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"File link not found."}`)) + return + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + results, err := client.resolveOfficialImageResults(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + }, ResponsesImageEvent{ + ConversationID: "conv-1", + SedimentIDs: []string{"file_img"}, + }) + if err != nil { + t.Fatalf("resolveOfficialImageResults() error = %v", err) + } + if urlAttempts != officialImageDownloadAttempts { + t.Fatalf("download URL attempts = %d, want %d", urlAttempts, officialImageDownloadAttempts) + } + if downloadAttempts != officialImageDownloadAttempts { + t.Fatalf("download attempts = %d, want %d", downloadAttempts, officialImageDownloadAttempts) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + +func TestResolveOfficialImageResultsUsesConversationScopedFileDownloadForSedimentID(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + + fileURLAttempts := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_img": + fileURLAttempts++ + if got := r.URL.Query().Get("conversation_id"); got != "conv-1" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/files/download/file_img" { + t.Fatalf("target path = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + results, err := client.resolveOfficialImageResults(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + }, ResponsesImageEvent{ + ConversationID: "conv-1", + SedimentIDs: []string{"file_img"}, + }) + if err != nil { + t.Fatalf("resolveOfficialImageResults() error = %v", err) + } + if fileURLAttempts != 1 { + t.Fatalf("file URL attempts = %d, want 1", fileURLAttempts) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + +func TestResolveOfficialImageResultsAuthenticatesBackendDownloadURL(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_img": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/backend-api/estuary/content?id=file_img&sig=test"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/estuary/content": + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/estuary/content" { + t.Fatalf("target path = %q", got) + } + if got := r.Header.Get("Accept"); got != "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" { + t.Fatalf("Accept = %q", got) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + results, err := client.resolveOfficialImageResults(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + }, ResponsesImageEvent{ + ConversationID: "conv-1", + FileIDs: []string{"file_img"}, + }) + if err != nil { + t.Fatalf("resolveOfficialImageResults() error = %v", err) } if len(results) != 1 || results[0].Result != png1x1 { t.Fatalf("results = %#v, want one final image result", results) @@ -406,6 +1436,8 @@ func TestShouldTreatOfficialImageEventAsFinalText(t *testing.T) { {name: "explicit no tool", event: ResponsesImageEvent{Text: "denied", ToolInvoked: &toolFalse, TurnUseCase: "multimodal"}, want: true}, {name: "text use case", event: ResponsesImageEvent{Text: "plain text", ToolInvoked: &toolTrue, TurnUseCase: "text"}, want: true}, {name: "queued notice still pending", event: ResponsesImageEvent{Text: "正在处理图片", ToolInvoked: nil, TurnUseCase: ""}, want: false}, + {name: "image generation queued notice still pending", event: ResponsesImageEvent{Text: "正在处理图片,图片准备好后我们会通知你。", ToolInvoked: nil, TurnUseCase: "image gen"}, want: false}, + {name: "image generation upstream text waits for explicit text marker", event: ResponsesImageEvent{Text: "上游返回的任何非排队文本都应该原样返回。", ToolInvoked: nil, TurnUseCase: "image gen"}, want: false}, {name: "image result present", event: ResponsesImageEvent{Text: "ignored", Result: "b64"}, want: false}, {name: "empty text", event: ResponsesImageEvent{Text: "", ToolInvoked: &toolFalse}, want: false}, } diff --git a/internal/backend/n_param_test.go b/internal/backend/n_param_test.go new file mode 100644 index 000000000..fef5d7b4e --- /dev/null +++ b/internal/backend/n_param_test.go @@ -0,0 +1,469 @@ +package backend + +import ( + "context" + "encoding/json" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "chatgpt2api/internal/service" + "chatgpt2api/internal/util" +) + +type nParamProxyConfig struct { + proxy string +} + +func (c nParamProxyConfig) Proxy() string { + return c.proxy +} + +// TestNParamSequential exercises sequential image calls with one token. +// Each call waits for the previous call to finish before starting the next one. +func TestNParamSequential(t *testing.T) { + token := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_ACCESS_TOKEN")) + if token == "" { + t.Skip("CHATGPT2API_DIAG_ACCESS_TOKEN is not set") + } + proxy := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROXY")) + n := intFromEnv("CHATGPT2API_DIAG_N", 2) + model := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_MODEL")), util.ImageModelGPT) + prompt := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROMPT")), "一只可爱的猫咪在草地上晒太阳,油画风格") + + client := NewClient(token, nil, service.NewProxyService(nParamProxyConfig{proxy: proxy})) + + t.Logf("=== 顺序调用测试 ===") + t.Logf("token=%s, model=%s, n=%d, prompt=%s", maskToken(token), model, n, prompt) + + start := time.Now() + successCount := 0 + var totalImages int + var totalDuration time.Duration + + for i := 1; i <= n; i++ { + iterStart := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + + events, errCh := client.StreamResponsesImage(ctx, ResponsesImageRequest{ + Prompt: prompt, + Model: model, + }) + + imagesForCall := 0 + conversationID := "" + hasError := false + + for event := range events { + if event.Result != "" { + imagesForCall++ + totalImages++ + } + if strings.TrimSpace(event.ConversationID) != "" { + conversationID = strings.TrimSpace(event.ConversationID) + } + } + + err := <-errCh + cancel() + elapsed := time.Since(iterStart) + + if err != nil { + hasError = true + t.Logf("[顺序 #%d/%d] 失败: %v (耗时 %v)", i, n, err, elapsed.Round(time.Millisecond)) + } else { + successCount++ + totalDuration += elapsed + t.Logf("[顺序 #%d/%d] 成功: %d 张图片, conversation=%s (耗时 %v)", i, n, imagesForCall, conversationID, elapsed.Round(time.Millisecond)) + } + + if hasError && i < n { + // Briefly wait before trying the next call after a failure. + select { + case <-time.After(2 * time.Second): + case <-ctx.Done(): + } + } + } + + totalTime := time.Since(start) + t.Logf("=== 顺序调用结果: 成功=%d/%d, 总图片=%d, 总耗时=%v, 平均成功耗时=%v ===", + successCount, n, totalImages, totalTime.Round(time.Millisecond), + func() time.Duration { + if successCount == 0 { + return 0 + } + return totalDuration / time.Duration(successCount) + }()) +} + +// TestNParamParallel exercises concurrent image calls across configured tokens. +// Tokens come from the comma-separated environment variable. +func TestNParamParallel(t *testing.T) { + tokenList := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_ACCESS_TOKENS")) + if tokenList == "" { + // Fall back to the single-token environment variable. + single := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_ACCESS_TOKEN")) + if single == "" { + t.Skip("CHATGPT2API_DIAG_ACCESS_TOKENS or CHATGPT2API_DIAG_ACCESS_TOKEN is not set") + } + tokenList = single + } + tokens := splitAndClean(tokenList) + if len(tokens) == 0 { + t.Skip("no tokens available") + } + proxy := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROXY")) + n := intFromEnv("CHATGPT2API_DIAG_N", 2) + model := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_MODEL")), util.ImageModelGPT) + prompt := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROMPT")), "一只可爱的猫咪在草地上晒太阳,油画风格") + + t.Logf("=== 并行调用测试 ===") + t.Logf("tokens=%d, model=%s, n=%d, prompt=%s", len(tokens), model, n, prompt) + + start := time.Now() + var wg sync.WaitGroup + + type callResult struct { + Index int + Success bool + Images int + ConversationID string + Duration time.Duration + Error string + Token string + } + + results := make([]callResult, n) + var totalImages int32 + + for i := 0; i < n; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + iterStart := time.Now() + + // Round-robin through the token pool. + token := tokens[idx%len(tokens)] + client := NewClient(token, nil, service.NewProxyService(nParamProxyConfig{proxy: proxy})) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + events, errCh := client.StreamResponsesImage(ctx, ResponsesImageRequest{ + Prompt: prompt, + Model: model, + }) + + var imagesForCall int32 + var conversationID string + for event := range events { + if event.Result != "" { + imagesForCall++ + } + if strings.TrimSpace(event.ConversationID) != "" { + conversationID = strings.TrimSpace(event.ConversationID) + } + } + + err := <-errCh + elapsed := time.Since(iterStart) + + results[idx] = callResult{ + Index: idx + 1, + Success: err == nil, + Images: int(imagesForCall), + ConversationID: conversationID, + Duration: elapsed, + Error: func() string { + if err != nil { + return err.Error() + } + return "" + }(), + Token: maskToken(token), + } + atomic.AddInt32(&totalImages, imagesForCall) + }(i) + } + + wg.Wait() + totalTime := time.Since(start) + + successCount := 0 + var totalSuccessDuration time.Duration + for _, r := range results { + if r.Success { + successCount++ + totalSuccessDuration += r.Duration + t.Logf("[并行 #%d] 成功: %d 张图片, token=%s, conversation=%s (耗时 %v)", + r.Index, r.Images, r.Token, r.ConversationID, r.Duration.Round(time.Millisecond)) + } else { + t.Logf("[并行 #%d] 失败: token=%s, err=%s (耗时 %v)", + r.Index, r.Token, r.Error, r.Duration.Round(time.Millisecond)) + } + } + + t.Logf("=== 并行调用结果: 成功=%d/%d, 总图片=%d, 总耗时=%v, 平均成功耗时=%v ===", + successCount, n, atomic.LoadInt32(&totalImages), totalTime.Round(time.Millisecond), + func() time.Duration { + if successCount == 0 { + return 0 + } + return totalSuccessDuration / time.Duration(successCount) + }()) +} + +// TestNParamComparison compares the sequential and parallel call modes. +func TestNParamComparison(t *testing.T) { + tokenList := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_ACCESS_TOKENS")) + singleToken := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_ACCESS_TOKEN")) + if tokenList == "" && singleToken == "" { + t.Skip("CHATGPT2API_DIAG_ACCESS_TOKENS or CHATGPT2API_DIAG_ACCESS_TOKEN is not set") + } + if tokenList == "" { + tokenList = singleToken + } + tokens := splitAndClean(tokenList) + if len(tokens) == 0 { + t.Skip("no tokens available") + } + + proxy := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROXY")) + n := intFromEnv("CHATGPT2API_DIAG_N", 2) + model := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_MODEL")), util.ImageModelGPT) + prompt := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROMPT")), "一只可爱的猫咪在草地上晒太阳,油画风格") + + t.Logf("=== n=%d 顺序 vs 并行 对比测试 ===", n) + t.Logf("可用 token 数=%d, model=%s", len(tokens), model) + + // ---- Sequential mode ---- + t.Log(">> 开始顺序调用...") + seqStart := time.Now() + seqSuccess := 0 + seqImages := 0 + var seqDurations []time.Duration + + for i := 1; i <= n; i++ { + iterStart := time.Now() + client := NewClient(tokens[0], nil, service.NewProxyService(nParamProxyConfig{proxy: proxy})) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + + events, errCh := client.StreamResponsesImage(ctx, ResponsesImageRequest{ + Prompt: prompt, + Model: model, + }) + + imagesForCall := 0 + for range events { + imagesForCall++ + } + err := <-errCh + cancel() + elapsed := time.Since(iterStart) + + if err != nil { + t.Logf(" [顺序 #%d] 失败: %v (耗时 %v)", i, err, elapsed.Round(time.Millisecond)) + } else { + seqSuccess++ + seqImages += imagesForCall + seqDurations = append(seqDurations, elapsed) + t.Logf(" [顺序 #%d] 成功: %d events (耗时 %v)", i, imagesForCall, elapsed.Round(time.Millisecond)) + } + } + seqTotal := time.Since(seqStart) + + // ---- Parallel mode ---- + t.Log(">> 开始并行调用...") + parStart := time.Now() + var parWg sync.WaitGroup + + type parResult struct { + Index int + Success bool + Events int + Duration time.Duration + Error string + } + + parResults := make([]parResult, n) + + for i := 0; i < n; i++ { + parWg.Add(1) + go func(idx int) { + defer parWg.Done() + iterStart := time.Now() + token := tokens[idx%len(tokens)] + client := NewClient(token, nil, service.NewProxyService(nParamProxyConfig{proxy: proxy})) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + events, errCh := client.StreamResponsesImage(ctx, ResponsesImageRequest{ + Prompt: prompt, + Model: model, + }) + + count := 0 + for range events { + count++ + } + err := <-errCh + elapsed := time.Since(iterStart) + + parResults[idx] = parResult{ + Index: idx + 1, + Success: err == nil, + Events: count, + Duration: elapsed, + Error: func() string { + if err != nil { + return err.Error() + } + return "" + }(), + } + }(i) + } + parWg.Wait() + parTotal := time.Since(parStart) + + parSuccess := 0 + parEvents := 0 + for _, r := range parResults { + if r.Success { + parSuccess++ + parEvents += r.Events + t.Logf(" [并行 #%d] 成功: %d events (耗时 %v)", r.Index, r.Events, r.Duration.Round(time.Millisecond)) + } else { + t.Logf(" [并行 #%d] 失败: %s (耗时 %v)", r.Index, r.Error, r.Duration.Round(time.Millisecond)) + } + } + + // ---- Comparison summary ---- + t.Log("") + t.Log("========== 对比总结 ==========") + t.Logf("顺序模式: 成功=%d/%d, 总事件=%d, 总耗时=%v", + seqSuccess, n, seqImages, seqTotal.Round(time.Millisecond)) + t.Logf("并行模式: 成功=%d/%d, 总事件=%d, 总耗时=%v", + parSuccess, n, parEvents, parTotal.Round(time.Millisecond)) + + if seqSuccess > 0 && parSuccess > 0 { + speedup := float64(seqTotal) / float64(parTotal) + t.Logf("加速比: %.2fx (顺序耗时/并行耗时)", speedup) + } + t.Log("==============================") +} + +// TestNParamDiagnostic runs a single diagnostic call and logs token usage plus timings. +func TestNParamDiagnostic(t *testing.T) { + token := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_ACCESS_TOKEN")) + if token == "" { + t.Skip("CHATGPT2API_DIAG_ACCESS_TOKEN is not set") + } + proxy := strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROXY")) + prompt := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_PROMPT")), "你好,你是什么模型?") + model := firstNonEmpty(strings.TrimSpace(os.Getenv("CHATGPT2API_DIAG_MODEL")), util.ImageModelGPT) + + client := NewClient(token, nil, service.NewProxyService(nParamProxyConfig{proxy: proxy})) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + t.Logf("token=%s, model=%s, prompt=%s", maskToken(token), model, prompt) + + events, errCh := client.StreamResponsesImage(ctx, ResponsesImageRequest{ + Prompt: prompt, + Model: model, + }) + + count := 0 + imageCount := 0 + conversationID := "" + start := time.Now() + + for event := range events { + count++ + if event.Result != "" { + imageCount++ + } + if strings.TrimSpace(event.ConversationID) != "" { + conversationID = strings.TrimSpace(event.ConversationID) + } + raw, _ := json.Marshal(event.Raw) + rawText := string(raw) + text := strings.TrimSpace(event.Text) + if len([]rune(text)) > 120 { + text = string([]rune(text)[:120]) + } + summary := map[string]any{ + "type": event.Type, + "has_conversation_id": strings.TrimSpace(event.ConversationID) != "", + "has_result": event.Result != "", + "text": text, + "blocked": event.Blocked, + "tool_invoked": func() any { + if event.ToolInvoked != nil { + return *event.ToolInvoked + } + return nil + }(), + "turn_use_case": event.TurnUseCase, + } + encoded, _ := json.Marshal(summary) + t.Logf("event[%d]=%s", count, encoded) + + // Detect key signals in the raw payload. + if strings.Contains(rawText, "asset_pointer") { + t.Logf(" -> raw 包含 asset_pointer") + } + if strings.Contains(rawText, "image_asset_pointer") { + t.Logf(" -> raw 包含 image_asset_pointer") + } + if strings.Contains(rawText, "image_gen") { + t.Logf(" -> raw 包含 async_image_gen") + } + } + + err := <-errCh + elapsed := time.Since(start) + + if err != nil { + t.Logf("stream_error=%v", err) + } + t.Logf("total_events=%d, images=%d, conversation=%s, duration=%v", + count, imageCount, conversationID, elapsed.Round(time.Millisecond)) +} + +func maskToken(token string) string { + if len(token) <= 8 { + return token[:min(len(token), 4)] + "***" + } + return token[:4] + "***" + token[len(token)-4:] +} + +func intFromEnv(key string, defaultVal int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return defaultVal + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return defaultVal + } + return n +} + +func splitAndClean(s string) []string { + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/backend/responses_image.go b/internal/backend/responses_image.go index ab7f62e33..5189ed391 100644 --- a/internal/backend/responses_image.go +++ b/internal/backend/responses_image.go @@ -13,6 +13,7 @@ import ( "io" "math" "net/http" + urlpkg "net/url" "regexp" "strconv" "strings" @@ -24,8 +25,8 @@ import ( ) const ( - officialImagePreparePath = "/backend-api/f/conversation/prepare" - officialImageStreamPath = "/backend-api/f/conversation" + officialPreparePath = "/backend-api/f/conversation/prepare" + officialStreamPath = "/backend-api/f/conversation" ResponsesImageMainModel = "gpt-5.4-mini" ResponsesImageCodexToolModel = "gpt-5.4-mini" @@ -40,8 +41,12 @@ const ( responsesImageMaxRatio = 3 responsesImageMinPixels = 655360 responsesImageMaxPixels = 8294400 + + officialImageDownloadAttempts = 3 ) +var officialImageDownloadRetryDelay = 750 * time.Millisecond + type ResponsesInputImage struct { Data []byte ContentType string @@ -60,11 +65,14 @@ type ResponsesImageRequest struct { PartialImages *int InputImages []ResponsesInputImage InputImageMask *ResponsesInputImage + ConversationID string + ParentMessageID string } type ResponsesImageEvent struct { Type string ItemID string + MessageID string Result string PartialImage string PartialImageIndex int @@ -83,6 +91,8 @@ type ResponsesImageEvent struct { ToolInvoked *bool TurnUseCase string Raw map[string]any + interpreterAssets []officialInterpreterAsset + pollTarget officialImagePollTarget } type uploadedImageRef struct { @@ -96,13 +106,44 @@ type uploadedImageRef struct { } type imageConversationState struct { - Text string + Text string + ConversationID string + MessageID string + FileIDs []string + SedimentIDs []string + InterpreterAssets []officialInterpreterAsset + Blocked bool + ToolInvoked *bool + TurnUseCase string + PollTarget officialImagePollTarget +} + +type officialImagePollTarget struct { + TurnExchangeID string + ImageGenTaskID string + MessageIDs []string +} + +func (t officialImagePollTarget) clone() officialImagePollTarget { + return officialImagePollTarget{ + TurnExchangeID: t.TurnExchangeID, + ImageGenTaskID: t.ImageGenTaskID, + MessageIDs: append([]string(nil), t.MessageIDs...), + } +} + +func (t officialImagePollTarget) hasTarget() bool { + return strings.TrimSpace(t.TurnExchangeID) != "" || strings.TrimSpace(t.ImageGenTaskID) != "" || len(t.MessageIDs) > 0 +} + +type officialInterpreterAsset struct { + AssetID string + MessageID string ConversationID string - FileIDs []string - SedimentIDs []string - Blocked bool - ToolInvoked *bool - TurnUseCase string + FileName string + MIMEType string + Width int + Height int } func (c *Client) StreamResponsesImage(ctx context.Context, request ResponsesImageRequest) (<-chan ResponsesImageEvent, <-chan error) { @@ -162,18 +203,18 @@ func (c *Client) streamOfficialResponsesImage(ctx context.Context, request Respo maskRef = &ref } streamPrompt := buildOfficialImagePrompt(prompt, request.Size) - conduitToken, err := c.prepareOfficialImageConversation(ctx, streamPrompt, reqs, request.Model) + conduitToken, err := c.prepareOfficialImageConversation(ctx, streamPrompt, reqs, request) if err != nil { return err } - resp, err := c.startOfficialImageConversation(ctx, streamPrompt, reqs, conduitToken, request.Model, attachments, maskRef) + resp, err := c.startOfficialImageConversation(ctx, streamPrompt, reqs, conduitToken, request, attachments, maskRef) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(resp.Body) - return upstreamHTTPError(officialImageStreamPath, resp.StatusCode, data) + return upstreamHTTPError(officialStreamPath, resp.StatusCode, data) } return iterOfficialImageSSE(ctx, c, resp.Body, request, out) } @@ -637,7 +678,7 @@ func parseOfficialImageDimensions(value string) (int, int, bool) { return width, height, true } -func (c *Client) officialImageHeaders(path string, reqs ChatRequirements, conduitToken, accept string) map[string]string { +func (c *Client) officialHeaders(path string, reqs ChatRequirements, conduitToken, accept string) map[string]string { extra := map[string]string{ "Content-Type": "application/json", "Accept": accept, @@ -661,12 +702,13 @@ func (c *Client) officialImageHeaders(path string, reqs ChatRequirements, condui return c.headers(path, extra) } -func (c *Client) prepareOfficialImageConversation(ctx context.Context, prompt string, reqs ChatRequirements, model string) (string, error) { +func (c *Client) prepareOfficialImageConversation(ctx context.Context, prompt string, reqs ChatRequirements, request ResponsesImageRequest) (string, error) { + parentMessageID := firstNonEmpty(strings.TrimSpace(request.ParentMessageID), util.NewUUID()) payload := map[string]any{ "action": "next", "fork_from_shared_post": false, - "parent_message_id": util.NewUUID(), - "model": officialImageModelSlug(model), + "parent_message_id": parentMessageID, + "model": officialImageModelSlug(request.Model), "client_prepare_state": "success", "timezone_offset_min": -480, "timezone": "Asia/Shanghai", @@ -683,12 +725,15 @@ func (c *Client) prepareOfficialImageConversation(ctx context.Context, prompt st "app_name": "chatgpt.com", }, } - resp, err := c.postJSON(ctx, officialImagePreparePath, payload, c.officialImageHeaders(officialImagePreparePath, reqs, "", "*/*"), false) + if conversationID := strings.TrimSpace(request.ConversationID); conversationID != "" { + payload["conversation_id"] = conversationID + } + resp, err := c.postJSON(ctx, officialPreparePath, payload, c.officialHeaders(officialPreparePath, reqs, "", "*/*"), false) if err != nil { return "", err } defer resp.Body.Close() - if err := ensureOK(resp, officialImagePreparePath); err != nil { + if err := ensureOK(resp, officialPreparePath); err != nil { return "", err } var data map[string]any @@ -794,7 +839,7 @@ func (c *Client) uploadImage(ctx context.Context, input ResponsesInputImage, fil }, nil } -func (c *Client) startOfficialImageConversation(ctx context.Context, prompt string, reqs ChatRequirements, conduitToken, model string, refs []uploadedImageRef, maskRef *uploadedImageRef) (*http.Response, error) { +func (c *Client) startOfficialImageConversation(ctx context.Context, prompt string, reqs ChatRequirements, conduitToken string, request ResponsesImageRequest, refs []uploadedImageRef, maskRef *uploadedImageRef) (*http.Response, error) { parts := make([]any, 0, len(refs)+2) for _, ref := range refs { parts = append(parts, map[string]any{ @@ -842,19 +887,21 @@ func (c *Client) startOfficialImageConversation(ctx context.Context, prompt stri } content["mask_pointer"] = "file-service://" + maskRef.FileID } + userMessageID := util.NewUUID() + parentMessageID := firstNonEmpty(strings.TrimSpace(request.ParentMessageID), util.NewUUID()) payload := map[string]any{ "action": "next", "messages": []any{ map[string]any{ - "id": util.NewUUID(), + "id": userMessageID, "author": map[string]any{"role": "user"}, "create_time": float64(time.Now().UnixNano()) / 1e9, "content": content, "metadata": metadata, }, }, - "parent_message_id": util.NewUUID(), - "model": officialImageModelSlug(model), + "parent_message_id": parentMessageID, + "model": officialImageModelSlug(request.Model), "client_prepare_state": "sent", "timezone_offset_min": -480, "timezone": "Asia/Shanghai", @@ -876,7 +923,13 @@ func (c *Client) startOfficialImageConversation(ctx context.Context, prompt stri "app_name": "chatgpt.com", }, } - return c.postJSON(ctx, officialImageStreamPath, payload, c.officialImageHeaders(officialImageStreamPath, reqs, conduitToken, "text/event-stream"), true) + headers := c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream") + if conversationID := strings.TrimSpace(request.ConversationID); conversationID != "" { + payload["conversation_id"] = conversationID + headers["OpenAI-Conversation-Id"] = conversationID + } + headers["OpenAI-Message-Id"] = userMessageID + return c.postJSON(ctx, officialStreamPath, payload, headers, true) } func iterOfficialImageSSE(ctx context.Context, client *Client, reader io.Reader, request ResponsesImageRequest, out chan<- ResponsesImageEvent) error { @@ -917,6 +970,25 @@ func iterOfficialImageSSE(ctx context.Context, client *Client, reader io.Reader, if shouldTreatOfficialImageEventAsFinalText(lastEvent) { return nil } + if strings.TrimSpace(lastEvent.Text) != "" && !isPendingOfficialImageText(lastEvent.Text) && !shouldResolveOfficialImageResults(lastEvent) { + lastEvent.Type = "image_text_response" + select { + case out <- lastEvent: + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + if textEvent, ok, err := client.resolveOfficialImageTextResponse(ctx, lastEvent); err != nil { + return err + } else if ok { + select { + case out <- textEvent: + case <-ctx.Done(): + return ctx.Err() + } + return nil + } resolved, err := client.resolveOfficialImageResults(ctx, request, lastEvent) if err != nil { return err @@ -928,12 +1000,76 @@ func iterOfficialImageSSE(ctx context.Context, client *Client, reader io.Reader, return ctx.Err() } } - if len(resolved) == 0 && strings.TrimSpace(lastEvent.Text) == "" { - return fmt.Errorf("image generation failed") + if len(resolved) == 0 && strings.TrimSpace(lastEvent.Text) != "" { + lastEvent.Type = "image_text_response" + select { + case out <- lastEvent: + case <-ctx.Done(): + return ctx.Err() + } + return nil } return nil } +func (c *Client) resolveOfficialImageTextResponse(ctx context.Context, event ResponsesImageEvent) (ResponsesImageEvent, bool, error) { + if !isOfficialImageTextResponseTurn(event) { + return ResponsesImageEvent{}, false, nil + } + text := strings.TrimSpace(event.Text) + if text == "" { + conversationID := strings.TrimSpace(event.ConversationID) + if conversationID != "" { + var err error + text, err = c.fetchOfficialConversationText(ctx, conversationID) + if err != nil { + return ResponsesImageEvent{}, false, err + } + } + } + if text == "" { + text = "Image generation returned a text response instead of image data." + } + event.Type = "image_text_response" + event.Text = text + return event, true, nil +} + +func isOfficialImageTextResponseTurn(event ResponsesImageEvent) bool { + if event.Blocked { + return true + } + if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { + return true + } + if shouldResolveOfficialImageResults(event) { + return false + } + return event.ToolInvoked != nil && !*event.ToolInvoked +} + +func isPendingOfficialImageText(text string) bool { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" { + return false + } + pendingPhrases := []string{ + "正在处理图片", + "图片准备好后", + "creating your image", + "working on your image", + "image is ready", + "we'll notify you", + "we will notify you", + } + for _, phrase := range pendingPhrases { + if strings.Contains(text, phrase) { + return true + } + } + return false +} + func shouldTreatOfficialImageEventAsFinalText(event ResponsesImageEvent) bool { if strings.TrimSpace(event.Text) == "" || event.Result != "" { return false @@ -944,9 +1080,38 @@ func shouldTreatOfficialImageEventAsFinalText(event ResponsesImageEvent) bool { if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { return true } + if shouldResolveOfficialImageResults(event) { + return false + } + if isOfficialImageGenerationUseCase(event.TurnUseCase) { + return false + } return event.ToolInvoked != nil && !*event.ToolInvoked } +func shouldResolveOfficialImageResults(event ResponsesImageEvent) bool { + if officialImageEventHasResultPointers(event) { + return true + } + if !isOfficialImageGenerationUseCase(event.TurnUseCase) { + return false + } + text := strings.TrimSpace(event.Text) + return text == "" || isPendingOfficialImageText(text) +} + +func officialImageEventHasResultPointers(event ResponsesImageEvent) bool { + return len(event.interpreterAssets) > 0 || len(filterOfficialImageIDs(event.FileIDs)) > 0 || len(filterOfficialImageIDs(event.SedimentIDs)) > 0 +} + +func isOfficialImageGenerationUseCase(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.ReplaceAll(normalized, "_", " ") + normalized = strings.ReplaceAll(normalized, "-", " ") + normalized = strings.Join(strings.Fields(normalized), " ") + return normalized == "image gen" || normalized == "image generation" +} + func parseOfficialImagePayload(payload string, state *imageConversationState) (ResponsesImageEvent, bool, error) { payload = strings.TrimSpace(payload) if payload == "" || payload == "[DONE]" { @@ -960,16 +1125,19 @@ func parseOfficialImagePayload(payload string, state *imageConversationState) (R updateOfficialImageConversationState(state, payload, data) eventType := util.Clean(data["type"]) event := ResponsesImageEvent{ - Type: eventType, - Created: time.Now().Unix(), - ConversationID: state.ConversationID, - FileIDs: append([]string(nil), state.FileIDs...), - SedimentIDs: append([]string(nil), state.SedimentIDs...), - Text: state.Text, - Blocked: state.Blocked, - ToolInvoked: state.ToolInvoked, - TurnUseCase: state.TurnUseCase, - Raw: data, + Type: eventType, + Created: time.Now().Unix(), + ConversationID: state.ConversationID, + MessageID: state.MessageID, + FileIDs: append([]string(nil), state.FileIDs...), + SedimentIDs: append([]string(nil), state.SedimentIDs...), + Text: state.Text, + Blocked: state.Blocked, + ToolInvoked: state.ToolInvoked, + TurnUseCase: state.TurnUseCase, + Raw: data, + interpreterAssets: append([]officialInterpreterAsset(nil), state.InterpreterAssets...), + pollTarget: state.PollTarget.clone(), } if message := officialImageTextMessage(data); message != "" && event.Result == "" { event.Text = message @@ -1006,6 +1174,13 @@ func updateOfficialImageConversationState(state *imageConversationState, payload if util.Clean(value["conversation_id"]) != "" { state.ConversationID = util.Clean(value["conversation_id"]) } + if messageID := assistantMessageIDFromOfficialImagePayload(event); messageID != "" { + state.MessageID = messageID + } + if assets := officialInterpreterAssetsFromEvent(event, state.ConversationID); len(assets) > 0 { + state.InterpreterAssets = assets + } + state.PollTarget = mergeOfficialImagePollTarget(state.PollTarget, officialImagePollTargetFromEvent(event)) if event["type"] == "moderation" { moderation := util.StringMap(event["moderation_response"]) if util.ToBool(moderation["blocked"]) { @@ -1034,6 +1209,131 @@ func updateOfficialImageConversationState(state *imageConversationState, payload } } +func officialImagePollTargetFromEvent(event map[string]any) officialImagePollTarget { + target := officialImagePollTarget{} + mergeMetadata := func(metadata map[string]any) { + if len(metadata) == 0 { + return + } + if turnExchangeID := util.Clean(metadata["turn_exchange_id"]); turnExchangeID != "" { + target.TurnExchangeID = turnExchangeID + } + if imageGenTaskID := util.Clean(metadata["image_gen_task_id"]); imageGenTaskID != "" { + target.ImageGenTaskID = imageGenTaskID + } + for _, key := range []string{"message_id", "parent_id"} { + if id := util.Clean(metadata[key]); id != "" { + target.MessageIDs = appendUniqueString(target.MessageIDs, id) + } + } + } + mergeMessage := func(message map[string]any) { + if len(message) == 0 { + return + } + if id := util.Clean(message["id"]); id != "" { + target.MessageIDs = appendUniqueString(target.MessageIDs, id) + } + mergeMetadata(util.StringMap(message["metadata"])) + } + mergeMetadata(util.StringMap(event["metadata"])) + if id := util.Clean(event["message_id"]); id != "" { + target.MessageIDs = appendUniqueString(target.MessageIDs, id) + } + mergeMessage(util.StringMap(event["message"])) + value := util.StringMap(event["v"]) + mergeMessage(util.StringMap(value["message"])) + if target.TurnExchangeID == "" && target.ImageGenTaskID == "" { + target.MessageIDs = nil + } + return target +} + +func mergeOfficialImagePollTarget(current, incoming officialImagePollTarget) officialImagePollTarget { + if !incoming.hasTarget() { + return current + } + if incoming.TurnExchangeID != "" && current.TurnExchangeID != "" && incoming.TurnExchangeID != current.TurnExchangeID { + current = officialImagePollTarget{} + } + if incoming.ImageGenTaskID != "" && current.ImageGenTaskID != "" && incoming.ImageGenTaskID != current.ImageGenTaskID { + current = officialImagePollTarget{} + } + if incoming.TurnExchangeID != "" { + current.TurnExchangeID = incoming.TurnExchangeID + } + if incoming.ImageGenTaskID != "" { + current.ImageGenTaskID = incoming.ImageGenTaskID + } + current.MessageIDs = appendUniqueString(current.MessageIDs, incoming.MessageIDs...) + return current +} + +func assistantMessageIDFromOfficialImagePayload(event map[string]any) string { + if id := assistantMessageIDFromOfficialMessage(util.StringMap(event["message"])); id != "" { + return id + } + if id := util.Clean(event["message_id"]); id != "" { + return id + } + value := util.StringMap(event["v"]) + return assistantMessageIDFromOfficialMessage(util.StringMap(value["message"])) +} + +func officialInterpreterAssetsFromEvent(event map[string]any, fallbackConversationID string) []officialInterpreterAsset { + message := util.StringMap(event["message"]) + if !isOfficialCodeInterpreterAssistantMessage(message) { + return nil + } + messageID := util.Clean(message["id"]) + conversationID := firstNonEmpty(util.Clean(event["conversation_id"]), fallbackConversationID) + content := util.StringMap(message["content"]) + var assets []officialInterpreterAsset + for _, rawAsset := range anySlice(content["assets"]) { + asset := util.StringMap(rawAsset) + assetID := util.Clean(asset["asset_id"]) + mimeType := strings.TrimSpace(util.Clean(asset["mime_type"])) + if assetID == "" || !strings.HasPrefix(strings.ToLower(mimeType), "image/") { + continue + } + assets = append(assets, officialInterpreterAsset{ + AssetID: assetID, + MessageID: messageID, + ConversationID: conversationID, + FileName: util.Clean(asset["file_name"]), + MIMEType: mimeType, + Width: util.ToInt(asset["width"], 0), + Height: util.ToInt(asset["height"], 0), + }) + } + return assets +} + +func isOfficialCodeInterpreterAssistantMessage(message map[string]any) bool { + if len(message) == 0 { + return false + } + author := util.StringMap(message["author"]) + if !strings.EqualFold(util.Clean(author["role"]), "assistant") { + return false + } + metadata := util.StringMap(message["metadata"]) + return util.Clean(metadata["async_task_type"]) == "code_interpreter" || util.Clean(metadata["tool"]) == "code_interpreter" +} + +func assistantMessageIDFromOfficialMessage(message map[string]any) string { + id := util.Clean(message["id"]) + if id == "" { + return "" + } + author := util.StringMap(message["author"]) + role := util.Clean(author["role"]) + if role != "" && !strings.EqualFold(role, "assistant") { + return "" + } + return id +} + func extractOfficialConversationIDs(payload string) (string, []string, []string) { conversation := "" if match := regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`).FindStringSubmatch(payload); len(match) > 1 { @@ -1064,7 +1364,39 @@ func isOfficialImageToolEvent(event map[string]any) bool { } metadata := util.StringMap(message["metadata"]) author := util.StringMap(message["author"]) - return strings.EqualFold(util.Clean(author["role"]), "tool") && util.Clean(metadata["async_task_type"]) == "image_gen" + if !strings.EqualFold(util.Clean(author["role"]), "tool") { + return false + } + return util.Clean(metadata["async_task_type"]) == "image_gen" || officialImageMessageHasAssetPointer(message) +} + +func officialImageMessageHasAssetPointer(message map[string]any) bool { + content := util.StringMap(message["content"]) + if util.Clean(content["content_type"]) != "multimodal_text" { + return false + } + parts, _ := content["parts"].([]any) + for _, rawPart := range parts { + switch part := rawPart.(type) { + case map[string]any: + if util.Clean(part["content_type"]) == "image_asset_pointer" { + return true + } + if isOfficialImageAssetPointer(util.Clean(part["asset_pointer"])) { + return true + } + case string: + if isOfficialImageAssetPointer(part) { + return true + } + } + } + return false +} + +func isOfficialImageAssetPointer(value string) bool { + value = strings.TrimSpace(value) + return strings.HasPrefix(value, "file-service://") || strings.HasPrefix(value, "sediment://") } func officialImageAssistantText(event map[string]any) string { @@ -1114,32 +1446,48 @@ func officialImageMessageText(message map[string]any) string { func (c *Client) resolveOfficialImageResults(ctx context.Context, request ResponsesImageRequest, event ResponsesImageEvent) ([]ResponsesImageEvent, error) { conversationID := strings.TrimSpace(event.ConversationID) + messageID := strings.TrimSpace(event.MessageID) + if len(event.interpreterAssets) > 0 { + return c.resolveOfficialInterpreterAssetResults(ctx, request, event.interpreterAssets, conversationID, messageID) + } fileIDs := filterOfficialImageIDs(event.FileIDs) sedimentIDs := filterOfficialImageIDs(event.SedimentIDs) + text := "" if conversationID != "" && len(fileIDs) == 0 && len(sedimentIDs) == 0 { - polledFiles, polledSediments, err := c.pollOfficialImageResults(ctx, conversationID, 120*time.Second) + polled, err := c.pollOfficialImageResults(ctx, conversationID, event.pollTarget) if err != nil { return nil, err } - fileIDs = appendUniqueString(fileIDs, polledFiles...) - sedimentIDs = appendUniqueString(sedimentIDs, polledSediments...) - } - urls, err := c.resolveOfficialImageURLs(ctx, conversationID, fileIDs, sedimentIDs) - if err != nil { - return nil, err + fileIDs = appendUniqueString(fileIDs, polled.FileIDs...) + sedimentIDs = appendUniqueString(sedimentIDs, polled.SedimentIDs...) + text = polled.Text + if messageID == "" { + messageID = polled.MessageID + } } - if len(urls) == 0 { + imageFileIDs := officialImageFileIDs(fileIDs, sedimentIDs) + if len(imageFileIDs) == 0 { + if strings.TrimSpace(text) != "" { + return []ResponsesImageEvent{{ + Type: "image_text_response", + MessageID: messageID, + Text: strings.TrimSpace(text), + Created: time.Now().Unix(), + ConversationID: conversationID, + }}, nil + } return nil, nil } - results := make([]ResponsesImageEvent, 0, len(urls)) - for index, url := range urls { - data, downloadErr := c.downloadOfficialImage(ctx, url) + results := make([]ResponsesImageEvent, 0, len(imageFileIDs)) + for index, fileID := range imageFileIDs { + data, downloadErr := c.downloadOfficialImageFile(ctx, conversationID, fileID) if downloadErr != nil { return nil, downloadErr } results = append(results, ResponsesImageEvent{ Type: "image_result", ItemID: fmt.Sprintf("image_%d", index+1), + MessageID: messageID, Result: base64.StdEncoding.EncodeToString(data), RevisedPrompt: strings.TrimSpace(request.Prompt), OutputFormat: "png", @@ -1152,6 +1500,41 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon return results, nil } +func (c *Client) resolveOfficialInterpreterAssetResults(ctx context.Context, request ResponsesImageRequest, assets []officialInterpreterAsset, fallbackConversationID, fallbackMessageID string) ([]ResponsesImageEvent, error) { + results := make([]ResponsesImageEvent, 0, len(assets)) + for index, asset := range assets { + conversationID := firstNonEmpty(strings.TrimSpace(asset.ConversationID), fallbackConversationID) + messageID := firstNonEmpty(strings.TrimSpace(asset.MessageID), fallbackMessageID) + data, err := c.downloadOfficialInterpreterAsset(ctx, conversationID, asset.AssetID, messageID) + if err != nil { + return nil, err + } + outputFormat := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(asset.MIMEType)), "image/") + if outputFormat == "" || outputFormat == "jpeg" { + outputFormat = "png" + } + results = append(results, ResponsesImageEvent{ + Type: "image_result", + ItemID: fmt.Sprintf("image_%d", index+1), + MessageID: messageID, + Result: base64.StdEncoding.EncodeToString(data), + RevisedPrompt: strings.TrimSpace(request.Prompt), + OutputFormat: outputFormat, + Created: time.Now().Unix(), + ConversationID: conversationID, + }) + } + return results, nil +} + +func officialImageFileIDs(fileIDs, sedimentIDs []string) []string { + out := appendUniqueString(nil, filterOfficialImageIDs(fileIDs)...) + for _, sedimentID := range sedimentIDs { + out = appendUniqueString(out, sedimentID) + } + return out +} + func filterOfficialImageIDs(values []string) []string { var out []string for _, value := range values { @@ -1164,29 +1547,53 @@ func filterOfficialImageIDs(values []string) []string { return out } -func (c *Client) pollOfficialImageResults(ctx context.Context, conversationID string, timeout time.Duration) ([]string, []string, error) { +type officialConversationPollResult struct { + FileIDs []string + SedimentIDs []string + Text string + MessageID string +} + +func (c *Client) pollOfficialImageResults(ctx context.Context, conversationID string, target officialImagePollTarget) (officialConversationPollResult, error) { if strings.TrimSpace(conversationID) == "" { - return nil, nil, nil + return officialConversationPollResult{}, nil } - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - fileIDs, sedimentIDs, err := c.fetchOfficialConversationImageIDs(ctx, conversationID) + delay := 4 * time.Second + for { + select { + case <-ctx.Done(): + return officialConversationPollResult{}, ctx.Err() + default: + } + result, err := c.fetchOfficialConversationImageResult(ctx, conversationID, target) if err != nil { - return nil, nil, err + if retry, ok := err.(officialConversationPollRetryError); ok { + delay = retry.Delay + } else { + return officialConversationPollResult{}, err + } } - if len(fileIDs) > 0 || len(sedimentIDs) > 0 { - return fileIDs, sedimentIDs, nil + if len(result.FileIDs) > 0 || len(result.SedimentIDs) > 0 || strings.TrimSpace(result.Text) != "" { + return result, nil } select { case <-ctx.Done(): - return nil, nil, ctx.Err() - case <-time.After(4 * time.Second): + return officialConversationPollResult{}, ctx.Err() + case <-time.After(delay): } + delay = 4 * time.Second } - return nil, nil, nil } -func (c *Client) fetchOfficialConversationImageIDs(ctx context.Context, conversationID string) ([]string, []string, error) { +type officialConversationPollRetryError struct { + Delay time.Duration +} + +func (e officialConversationPollRetryError) Error() string { + return "official conversation poll rate limited" +} + +func (c *Client) fetchOfficialConversationImageResult(ctx context.Context, conversationID string, target officialImagePollTarget) (officialConversationPollResult, error) { path := "/backend-api/conversation/" + conversationID req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) for key, value := range c.headers(path, map[string]string{"Accept": "application/json"}) { @@ -1194,86 +1601,208 @@ func (c *Client) fetchOfficialConversationImageIDs(ctx context.Context, conversa } resp, err := c.httpClient.Do(req) if err != nil { - return nil, nil, upstreamTransportError(path, err) + return officialConversationPollResult{}, upstreamTransportError(path, err) } defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + io.Copy(io.Discard, resp.Body) + return officialConversationPollResult{}, officialConversationPollRetryError{Delay: officialConversationPollRetryDelay(resp.Header.Get("Retry-After"))} + } if err := ensureOK(resp, path); err != nil { - return nil, nil, err + return officialConversationPollResult{}, err } var data map[string]any if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, nil, err + return officialConversationPollResult{}, err } - mapping := util.StringMap(data["mapping"]) + return officialConversationPollResultFromDataForTarget(data, target), nil +} + +func officialConversationPollResultFromData(data map[string]any) officialConversationPollResult { + return officialConversationPollResultFromDataForTarget(data, officialImagePollTarget{}) +} + +func officialConversationPollResultFromDataForTarget(data map[string]any, target officialImagePollTarget) officialConversationPollResult { + text := officialConversationAssistantText(data) + messageID := officialConversationAssistantMessageID(data) var fileIDs []string var sedimentIDs []string - for _, raw := range mapping { + for _, message := range latestOfficialConversationImageToolMessagesForTarget(data, target) { + messageFileIDs, messageSedimentIDs := officialImageAssetPointersFromMessage(message) + fileIDs = appendUniqueString(fileIDs, messageFileIDs...) + sedimentIDs = appendUniqueString(sedimentIDs, messageSedimentIDs...) + } + if len(fileIDs) > 0 || len(sedimentIDs) > 0 || isPendingOfficialImageText(text) || target.hasTarget() { + text = "" + } + return officialConversationPollResult{FileIDs: fileIDs, SedimentIDs: sedimentIDs, Text: text, MessageID: messageID} +} + +func latestOfficialConversationImageToolMessages(data map[string]any) []map[string]any { + return latestOfficialConversationImageToolMessagesForTarget(data, officialImagePollTarget{}) +} + +func latestOfficialConversationImageToolMessagesForTarget(data map[string]any, target officialImagePollTarget) []map[string]any { + mapping := util.StringMap(data["mapping"]) + var messages []map[string]any + bestTime := -1.0 + for key, raw := range mapping { node, ok := raw.(map[string]any) if !ok { continue } message := util.StringMap(node["message"]) - author := util.StringMap(message["author"]) - metadata := util.StringMap(message["metadata"]) - content := util.StringMap(message["content"]) - if !strings.EqualFold(util.Clean(author["role"]), "tool") || util.Clean(metadata["async_task_type"]) != "image_gen" { + if !isOfficialConversationImageToolMessage(message) { continue } - if util.Clean(content["content_type"]) != "multimodal_text" { + if target.hasTarget() && !officialConversationNodeMatchesPollTarget(key, node, message, mapping, target, map[string]bool{}) { continue } - if parts, ok := content["parts"].([]any); ok { - for _, rawPart := range parts { - text := "" - if item, ok := rawPart.(map[string]any); ok { - text = util.Clean(item["asset_pointer"]) - } else if value, ok := rawPart.(string); ok { - text = value - } - for _, match := range regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`).FindAllStringSubmatch(text, -1) { - if len(match) > 1 { - fileIDs = appendUniqueString(fileIDs, match[1]) - } - } - for _, match := range regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`).FindAllStringSubmatch(text, -1) { - if len(match) > 1 { - sedimentIDs = appendUniqueString(sedimentIDs, match[1]) - } - } - } + fileIDs, sedimentIDs := officialImageAssetPointersFromMessage(message) + if len(fileIDs) == 0 && len(sedimentIDs) == 0 { + continue + } + messageTime := officialImageMessageTimestamp(message) + if messageTime > bestTime { + messages = []map[string]any{message} + bestTime = messageTime + continue + } + if messageTime == bestTime { + messages = append(messages, message) } } - return fileIDs, sedimentIDs, nil + return messages } -func (c *Client) resolveOfficialImageURLs(ctx context.Context, conversationID string, fileIDs, sedimentIDs []string) ([]string, error) { - var urls []string - for _, fileID := range fileIDs { - url, err := c.getOfficialFileDownloadURL(ctx, fileID) - if err != nil { - continue +func officialConversationNodeMatchesPollTarget(key string, node, message map[string]any, mapping map[string]any, target officialImagePollTarget, seen map[string]bool) bool { + if !target.hasTarget() { + return true + } + if key != "" { + if seen[key] { + return false } - if strings.TrimSpace(url) != "" { - urls = append(urls, url) + seen[key] = true + } + if officialConversationMessageMatchesPollTarget(key, node, message, target) { + return true + } + parentKey := firstNonEmpty(util.Clean(node["parent"]), util.Clean(node["parent_id"])) + if parentKey == "" { + return false + } + parentNode := util.StringMap(mapping[parentKey]) + if len(parentNode) == 0 { + return officialImagePollTargetContainsMessageID(target, parentKey) + } + return officialConversationNodeMatchesPollTarget(parentKey, parentNode, util.StringMap(parentNode["message"]), mapping, target, seen) +} + +func officialConversationMessageMatchesPollTarget(key string, node, message map[string]any, target officialImagePollTarget) bool { + metadata := util.StringMap(message["metadata"]) + if target.TurnExchangeID != "" && util.Clean(metadata["turn_exchange_id"]) == target.TurnExchangeID { + return true + } + if target.ImageGenTaskID != "" && util.Clean(metadata["image_gen_task_id"]) == target.ImageGenTaskID { + return true + } + for _, id := range []string{ + key, + util.Clean(message["id"]), + util.Clean(metadata["message_id"]), + util.Clean(metadata["parent_id"]), + util.Clean(node["parent"]), + util.Clean(node["parent_id"]), + } { + if officialImagePollTargetContainsMessageID(target, id) { + return true } } - if len(urls) > 0 || strings.TrimSpace(conversationID) == "" { - return urls, nil + return false +} + +func officialImagePollTargetContainsMessageID(target officialImagePollTarget, id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false } - for _, sedimentID := range sedimentIDs { - url, err := c.getOfficialAttachmentDownloadURL(ctx, conversationID, sedimentID) - if err != nil { - continue + for _, candidate := range target.MessageIDs { + if strings.TrimSpace(candidate) == id { + return true } - if strings.TrimSpace(url) != "" { - urls = append(urls, url) + } + return false +} + +func isOfficialConversationImageToolMessage(message map[string]any) bool { + if len(message) == 0 { + return false + } + author := util.StringMap(message["author"]) + if !strings.EqualFold(util.Clean(author["role"]), "tool") { + return false + } + content := util.StringMap(message["content"]) + if util.Clean(content["content_type"]) != "multimodal_text" { + return false + } + metadata := util.StringMap(message["metadata"]) + return util.Clean(metadata["async_task_type"]) == "image_gen" || officialImageMessageHasAssetPointer(message) +} + +func officialImageAssetPointersFromMessage(message map[string]any) ([]string, []string) { + content := util.StringMap(message["content"]) + parts, _ := content["parts"].([]any) + var fileIDs []string + var sedimentIDs []string + for _, rawPart := range parts { + text := "" + switch part := rawPart.(type) { + case map[string]any: + text = util.Clean(part["asset_pointer"]) + case string: + text = part + } + for _, match := range regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`).FindAllStringSubmatch(text, -1) { + if len(match) > 1 { + fileIDs = appendUniqueString(fileIDs, match[1]) + } + } + for _, match := range regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`).FindAllStringSubmatch(text, -1) { + if len(match) > 1 { + sedimentIDs = appendUniqueString(sedimentIDs, match[1]) + } } } - return urls, nil + return fileIDs, sedimentIDs } -func (c *Client) getOfficialFileDownloadURL(ctx context.Context, fileID string) (string, error) { - path := "/backend-api/files/" + fileID + "/download" +func officialConversationPollRetryDelay(value string) time.Duration { + value = strings.TrimSpace(value) + if value == "" { + return 4 * time.Second + } + if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 { + if seconds > 30 { + seconds = 30 + } + return time.Duration(seconds) * time.Second + } + if when, err := http.ParseTime(value); err == nil { + delay := time.Until(when) + if delay > 0 { + if delay > 30*time.Second { + return 30 * time.Second + } + return delay + } + } + return 4 * time.Second +} + +func (c *Client) fetchOfficialConversationText(ctx context.Context, conversationID string) (string, error) { + path := "/backend-api/conversation/" + conversationID req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) for key, value := range c.headers(path, map[string]string{"Accept": "application/json"}) { req.Header.Set(key, value) @@ -1290,13 +1819,137 @@ func (c *Client) getOfficialFileDownloadURL(ctx context.Context, fileID string) if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return "", err } - return firstNonEmpty(util.Clean(data["download_url"]), util.Clean(data["url"])), nil + return officialConversationAssistantText(data), nil +} + +func officialConversationAssistantText(data map[string]any) string { + message := latestOfficialConversationAssistantMessage(data) + return strings.TrimSpace(officialImageMessageText(message)) +} + +func officialConversationAssistantMessageID(data map[string]any) string { + return util.Clean(latestOfficialConversationAssistantMessage(data)["id"]) +} + +func latestOfficialConversationAssistantMessage(data map[string]any) map[string]any { + mapping := util.StringMap(data["mapping"]) + var bestMessage map[string]any + bestTime := -1.0 + for _, raw := range mapping { + node, ok := raw.(map[string]any) + if !ok { + continue + } + message := util.StringMap(node["message"]) + if !isOfficialVisibleAssistantMessage(message) { + continue + } + messageTime := officialImageMessageTimestamp(message) + if bestMessage == nil || messageTime >= bestTime { + bestMessage = message + bestTime = messageTime + } + } + return bestMessage +} + +func isOfficialVisibleAssistantMessage(message map[string]any) bool { + if len(message) == 0 { + return false + } + author := util.StringMap(message["author"]) + if !strings.EqualFold(util.Clean(author["role"]), "assistant") { + return false + } + recipient := util.Clean(message["recipient"]) + if recipient != "" && !strings.EqualFold(recipient, "all") { + return false + } + metadata := util.StringMap(message["metadata"]) + if util.ToBool(metadata["is_visually_hidden_from_conversation"]) { + return false + } + return true +} + +func officialImageMessageTimestamp(message map[string]any) float64 { + for _, key := range []string{"update_time", "create_time"} { + switch value := message[key].(type) { + case float64: + if value > 0 { + return value + } + case int: + if value > 0 { + return float64(value) + } + case int64: + if value > 0 { + return float64(value) + } + case json.Number: + if parsed, err := strconv.ParseFloat(value.String(), 64); err == nil && parsed > 0 { + return parsed + } + case string: + if parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && parsed > 0 { + return parsed + } + } + } + return 0 } -func (c *Client) getOfficialAttachmentDownloadURL(ctx context.Context, conversationID, attachmentID string) (string, error) { - path := "/backend-api/conversation/" + conversationID + "/attachment/" + attachmentID + "/download" +func (c *Client) downloadOfficialInterpreterAsset(ctx context.Context, conversationID, assetID, messageID string) ([]byte, error) { + conversationID = strings.TrimSpace(conversationID) + assetID = strings.TrimSpace(assetID) + messageID = strings.TrimSpace(messageID) + if conversationID == "" { + return nil, fmt.Errorf("conversation_id is required for official interpreter asset download") + } + if assetID == "" { + return nil, fmt.Errorf("asset_id is required for official interpreter asset download") + } + if messageID == "" { + return nil, fmt.Errorf("message_id is required for official interpreter asset download") + } + query := urlpkg.Values{} + query.Set("asset_id", assetID) + query.Set("message_id", messageID) + targetPath := "/backend-api/conversation/" + urlpkg.PathEscape(conversationID) + "/interpreter/download" + path := targetPath + "?" + query.Encode() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) - for key, value := range c.headers(path, map[string]string{"Accept": "application/json"}) { + for key, value := range c.headers(targetPath, map[string]string{"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"}) { + req.Header.Set(key, value) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, upstreamTransportError(path, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return nil, upstreamHTTPError(path, resp.StatusCode, data) + } + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if contentType != "" && !strings.HasPrefix(contentType, "image/") { + return nil, fmt.Errorf("official interpreter asset %s returned non-image content type %s", assetID, contentType) + } + return io.ReadAll(resp.Body) +} + +func (c *Client) getOfficialFileDownloadURL(ctx context.Context, conversationID, fileID string) (string, error) { + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" { + return "", fmt.Errorf("conversation_id is required for official image download") + } + query := urlpkg.Values{} + query.Set("conversation_id", conversationID) + query.Set("inline", "false") + targetPath := "/backend-api/files/download/" + urlpkg.PathEscape(fileID) + path := targetPath + "?" + query.Encode() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + for key, value := range c.headers(targetPath, map[string]string{"Accept": "application/json"}) { req.Header.Set(key, value) } resp, err := c.httpClient.Do(req) @@ -1314,8 +1967,56 @@ func (c *Client) getOfficialAttachmentDownloadURL(ctx context.Context, conversat return firstNonEmpty(util.Clean(data["download_url"]), util.Clean(data["url"])), nil } +func (c *Client) downloadOfficialImageFile(ctx context.Context, conversationID, fileID string) ([]byte, error) { + var lastErr error + for attempt := 1; attempt <= officialImageDownloadAttempts; attempt++ { + downloadURL, err := c.getOfficialFileDownloadURL(ctx, conversationID, fileID) + if err == nil && strings.TrimSpace(downloadURL) == "" { + err = fmt.Errorf("official image file %s returned empty download URL", fileID) + } + if err == nil { + var data []byte + data, err = c.downloadOfficialImage(ctx, downloadURL) + if err == nil { + return data, nil + } + } + lastErr = err + if attempt == officialImageDownloadAttempts { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(officialImageDownloadRetryDelay): + } + } + return nil, lastErr +} + func (c *Client) downloadOfficialImage(ctx context.Context, url string) ([]byte, error) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + target := strings.TrimSpace(url) + parsed, err := urlpkg.Parse(target) + if err != nil { + return nil, err + } + if !parsed.IsAbs() { + base, baseErr := urlpkg.Parse(c.BaseURL) + if baseErr != nil { + return nil, baseErr + } + parsed = base.ResolveReference(parsed) + target = parsed.String() + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if c.isChatGPTBackendURL(parsed) { + path := parsed.EscapedPath() + for key, value := range c.headers(path, map[string]string{"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"}) { + req.Header.Set(key, value) + } + } else if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } resp, err := c.httpClient.Do(req) if err != nil { return nil, upstreamTransportError("image_download", err) @@ -1328,6 +2029,21 @@ func (c *Client) downloadOfficialImage(ctx context.Context, url string) ([]byte, return io.ReadAll(resp.Body) } +func (c *Client) isChatGPTBackendURL(parsed *urlpkg.URL) bool { + if parsed == nil { + return false + } + base, err := urlpkg.Parse(c.BaseURL) + if err != nil || base.Host == "" { + return false + } + if !strings.EqualFold(parsed.Host, base.Host) { + return false + } + path := parsed.EscapedPath() + return strings.HasPrefix(path, "/backend-api/") || strings.HasPrefix(path, "/backend-anon/") +} + func appendUniqueString(base []string, values ...string) []string { seen := map[string]struct{}{} for _, item := range base { diff --git a/internal/config/config.go b/internal/config/config.go index 3245a9d3b..2f1f214a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,10 +25,16 @@ var settingEnvKeys = map[string]string{ "image_task_timeout_seconds": "CHATGPT2API_IMAGE_TASK_TIMEOUT_SECONDS", "user_default_concurrent_limit": "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT", "user_default_rpm_limit": "CHATGPT2API_USER_DEFAULT_RPM_LIMIT", + "default_billing_type": "CHATGPT2API_DEFAULT_BILLING_TYPE", + "default_standard_balance": "CHATGPT2API_DEFAULT_STANDARD_BALANCE", + "default_subscription_quota": "CHATGPT2API_DEFAULT_SUBSCRIPTION_QUOTA", + "default_subscription_period": "CHATGPT2API_DEFAULT_SUBSCRIPTION_PERIOD", "image_retention_days": "CHATGPT2API_IMAGE_RETENTION_DAYS", + "image_storage_limit_mb": "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB", "auto_remove_invalid_accounts": "CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS", "auto_remove_rate_limited_accounts": "CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS", "log_retention_days": "CHATGPT2API_LOG_RETENTION_DAYS", + "default_log_view": "CHATGPT2API_DEFAULT_LOG_VIEW", "log_levels": "CHATGPT2API_LOG_LEVELS", "linuxdo_enabled": "CHATGPT2API_LINUXDO_ENABLED", "linuxdo_client_id": "CHATGPT2API_LINUXDO_CLIENT_ID", @@ -54,13 +60,12 @@ const ( ) type Store struct { - mu sync.RWMutex - RootDir string - DataDir string - EnvFile string - data map[string]any - externalEnvKeys map[string]struct{} - storageBackend storage.Backend + mu sync.RWMutex + RootDir string + DataDir string + EnvFile string + data map[string]any + storageBackend storage.Backend } type LinuxDoOAuthConfig struct { @@ -89,18 +94,10 @@ func NewStore() (*Store, error) { envFile := filepath.Join(root, ".env") envFileValues := readEnvObject(envFile) s := &Store{ - RootDir: root, - DataDir: filepath.Join(root, "data"), - EnvFile: envFile, - data: map[string]any{}, - externalEnvKeys: map[string]struct{}{}, - } - for _, item := range os.Environ() { - key, value, _ := strings.Cut(item, "=") - if fileValue, ok := envFileValues[key]; ok && value == fileValue { - continue - } - s.externalEnvKeys[key] = struct{}{} + RootDir: root, + DataDir: filepath.Join(root, "data"), + EnvFile: envFile, + data: map[string]any{}, } if err := os.MkdirAll(s.DataDir, 0o755); err != nil { return nil, err @@ -191,6 +188,22 @@ func (s *Store) ImageRetentionDays() int { return value } +func (s *Store) ImageStorageLimitMB() int { + value := intSetting(s.settingValue("image_storage_limit_mb", 0), 0) + if value < 0 { + return 0 + } + return value +} + +func (s *Store) ImageStorageLimitBytes() int64 { + mb := s.ImageStorageLimitMB() + if mb <= 0 { + return 0 + } + return int64(mb) * 1024 * 1024 +} + func (s *Store) LogRetentionDays() int { value := intSetting(s.settingValue("log_retention_days", 7), 7) if value < 1 { @@ -202,6 +215,10 @@ func (s *Store) LogRetentionDays() int { return value } +func (s *Store) DefaultLogView() string { + return normalizeDefaultLogView(s.settingValue("default_log_view", "meaningful")) +} + func (s *Store) ImageTaskTimeoutSeconds() int { return normalizeImageTaskTimeoutSeconds(s.settingValue("image_task_timeout_seconds", defaultImageTaskTimeoutSeconds)) } @@ -222,6 +239,40 @@ func (s *Store) UserDefaultRPMLimit() int { return value } +func (s *Store) DefaultBillingType() string { + switch strings.ToLower(strings.TrimSpace(fmt.Sprint(s.settingValue("default_billing_type", "standard")))) { + case "subscription": + return "subscription" + default: + return "standard" + } +} + +func (s *Store) DefaultStandardBalance() int { + value := intSetting(s.settingValue("default_standard_balance", 0), 0) + if value < 0 { + return 0 + } + return value +} + +func (s *Store) DefaultSubscriptionQuota() int { + value := intSetting(s.settingValue("default_subscription_quota", 0), 0) + if value < 0 { + return 0 + } + return value +} + +func (s *Store) DefaultSubscriptionPeriod() string { + switch strings.ToLower(strings.TrimSpace(fmt.Sprint(s.settingValue("default_subscription_period", "monthly")))) { + case "daily", "weekly", "monthly": + return strings.ToLower(strings.TrimSpace(fmt.Sprint(s.settingValue("default_subscription_period", "monthly")))) + default: + return "monthly" + } +} + func (s *Store) AutoRemoveInvalidAccounts() bool { return util.ToBool(s.settingValue("auto_remove_invalid_accounts", false)) } @@ -378,8 +429,14 @@ func (s *Store) Get() map[string]any { data["image_task_timeout_seconds"] = s.ImageTaskTimeoutSeconds() data["user_default_concurrent_limit"] = s.UserDefaultConcurrentLimit() data["user_default_rpm_limit"] = s.UserDefaultRPMLimit() + data["default_billing_type"] = s.DefaultBillingType() + data["default_standard_balance"] = s.DefaultStandardBalance() + data["default_subscription_quota"] = s.DefaultSubscriptionQuota() + data["default_subscription_period"] = s.DefaultSubscriptionPeriod() data["image_retention_days"] = s.ImageRetentionDays() + data["image_storage_limit_mb"] = s.ImageStorageLimitMB() data["log_retention_days"] = s.LogRetentionDays() + data["default_log_view"] = s.DefaultLogView() data["auto_remove_invalid_accounts"] = s.AutoRemoveInvalidAccounts() data["auto_remove_rate_limited_accounts"] = s.AutoRemoveRateLimitedAccounts() data["log_levels"] = s.LogLevels() @@ -429,6 +486,18 @@ func (s *Store) Update(data map[string]any) (map[string]any, error) { if value, ok := next["image_task_timeout_seconds"]; ok { next["image_task_timeout_seconds"] = normalizeImageTaskTimeoutSeconds(value) } + if value, ok := next["image_storage_limit_mb"]; ok { + next["image_storage_limit_mb"] = normalizeNonNegativeInt(value) + } + if value, ok := next["default_billing_type"]; ok { + next["default_billing_type"] = normalizeDefaultBillingType(value) + } + if value, ok := next["default_subscription_period"]; ok { + next["default_subscription_period"] = normalizeDefaultSubscriptionPeriod(value) + } + if value, ok := next["default_log_view"]; ok { + next["default_log_view"] = normalizeDefaultLogView(value) + } next["update_repo"] = normalizeUpdateRepo(util.ValueOr(next["update_repo"], "ZyphrZero/chatgpt2api")) if err := s.validateSettingsUpdateLocked(next); err != nil { s.mu.Unlock() @@ -480,32 +549,27 @@ func (s *Store) StorageBackend() (storage.Backend, error) { func (s *Store) settingValue(key string, fallback any) any { envKey := settingEnvKeys[key] - if value, ok := os.LookupEnv(envKey); ok { - return value - } s.mu.RLock() - defer s.mu.RUnlock() if value, ok := s.data[key]; ok { + s.mu.RUnlock() return value } - return fallback -} - -func (s *Store) settingValueFromData(data map[string]any, key string, fallback any) any { - envKey := settingEnvKeys[key] + s.mu.RUnlock() if envKey != "" { if value, ok := os.LookupEnv(envKey); ok { - if _, external := s.externalEnvKeys[envKey]; external { - return value - } + return value } } + return fallback +} + +func (s *Store) settingValueFromData(data map[string]any, key string, fallback any) any { if data != nil { if value, ok := data[key]; ok { return value } } - if envKey != "" { + if envKey := settingEnvKeys[key]; envKey != "" { if value, ok := os.LookupEnv(envKey); ok { return value } @@ -551,6 +615,15 @@ func (s *Store) validateSettingsUpdateLocked(data map[string]any) error { return nil } +func normalizeDefaultLogView(value any) string { + switch strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) { + case "all", "meaningful", "business": + return strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) + default: + return "meaningful" + } +} + func normalizeUpdateRepo(value any) string { repo := strings.Trim(strings.TrimSpace(fmt.Sprint(value)), "/") if repo == "" { @@ -620,9 +693,7 @@ func (s *Store) saveLocked() error { return err } for key, value := range updates { - if _, external := s.externalEnvKeys[key]; !external { - _ = os.Setenv(key, value) - } + _ = os.Setenv(key, value) } return nil } @@ -727,6 +798,32 @@ func normalizeImageTaskTimeoutSeconds(value any) int { return seconds } +func normalizeNonNegativeInt(value any) int { + n := intSetting(value, 0) + if n < 0 { + return 0 + } + return n +} + +func normalizeDefaultBillingType(value any) string { + switch strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) { + case "subscription": + return "subscription" + default: + return "standard" + } +} + +func normalizeDefaultSubscriptionPeriod(value any) string { + switch strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) { + case "daily", "weekly", "monthly": + return strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) + default: + return "monthly" + } +} + func clampFloat(value, min, max float64) float64 { if value < min { return min diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e689010c3..5faf24bfa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,7 +17,9 @@ func TestStoreUpdatePersistsRuntimeSettings(t *testing.T) { unsetEnv(t, "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT") unsetEnv(t, "CHATGPT2API_USER_DEFAULT_RPM_LIMIT") unsetEnv(t, "CHATGPT2API_IMAGE_RETENTION_DAYS") + unsetEnv(t, "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB") unsetEnv(t, "CHATGPT2API_LOG_RETENTION_DAYS") + unsetEnv(t, "CHATGPT2API_DEFAULT_LOG_VIEW") unsetEnv(t, "CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS") unsetEnv(t, "CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS") unsetEnv(t, "CHATGPT2API_REGISTRATION_ENABLED") @@ -38,7 +40,9 @@ func TestStoreUpdatePersistsRuntimeSettings(t *testing.T) { "user_default_concurrent_limit": 2, "user_default_rpm_limit": 30, "image_retention_days": 14, + "image_storage_limit_mb": 512, "log_retention_days": 21, + "default_log_view": "business", "registration_enabled": true, "log_levels": []any{"debug", "error"}, }) @@ -48,6 +52,10 @@ func TestStoreUpdatePersistsRuntimeSettings(t *testing.T) { if store.BaseURL() != "https://example.test/root" { t.Fatalf("BaseURL() = %q", store.BaseURL()) } + if store.DefaultLogView() != "business" { + t.Fatalf("DefaultLogView() = %q, want business", store.DefaultLogView()) + } + assertConfigValue(t, got, "default_log_view", "business") assertConfigValue(t, got, "registration_enabled", true) if _, ok := got["image_concurrent_limit"]; ok { t.Fatalf("removed image_concurrent_limit leaked in config response: %#v", got) @@ -66,7 +74,9 @@ func TestStoreUpdatePersistsRuntimeSettings(t *testing.T) { "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT=2", "CHATGPT2API_USER_DEFAULT_RPM_LIMIT=30", "CHATGPT2API_IMAGE_RETENTION_DAYS=14", + "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB=512", "CHATGPT2API_LOG_RETENTION_DAYS=21", + "CHATGPT2API_DEFAULT_LOG_VIEW=business", "CHATGPT2API_REGISTRATION_ENABLED=true", "CHATGPT2API_LOG_LEVELS=debug,error", } { @@ -247,6 +257,7 @@ func TestStoreUpdateRefreshesEnvFileBackedRuntimeSettings(t *testing.T) { "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT=2", "CHATGPT2API_USER_DEFAULT_RPM_LIMIT=30", "CHATGPT2API_IMAGE_RETENTION_DAYS=30", + "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB=2048", "CHATGPT2API_LOG_RETENTION_DAYS=7", "CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS=true", "CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS=false", @@ -264,6 +275,7 @@ func TestStoreUpdateRefreshesEnvFileBackedRuntimeSettings(t *testing.T) { t.Setenv("CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT", "2") t.Setenv("CHATGPT2API_USER_DEFAULT_RPM_LIMIT", "30") t.Setenv("CHATGPT2API_IMAGE_RETENTION_DAYS", "30") + t.Setenv("CHATGPT2API_IMAGE_STORAGE_LIMIT_MB", "2048") t.Setenv("CHATGPT2API_LOG_RETENTION_DAYS", "7") t.Setenv("CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS", "true") t.Setenv("CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS", "false") @@ -281,6 +293,7 @@ func TestStoreUpdateRefreshesEnvFileBackedRuntimeSettings(t *testing.T) { "user_default_concurrent_limit": 3, "user_default_rpm_limit": 45, "image_retention_days": 12, + "image_storage_limit_mb": 1024, "log_retention_days": 30, "auto_remove_invalid_accounts": false, "auto_remove_rate_limited_accounts": true, @@ -297,6 +310,10 @@ func TestStoreUpdateRefreshesEnvFileBackedRuntimeSettings(t *testing.T) { assertConfigValue(t, got, "user_default_concurrent_limit", 3) assertConfigValue(t, got, "user_default_rpm_limit", 45) assertConfigValue(t, got, "image_retention_days", 12) + assertConfigValue(t, got, "image_storage_limit_mb", 1024) + if store.ImageStorageLimitBytes() != 1024*1024*1024 { + t.Fatalf("ImageStorageLimitBytes() = %d, want 1GiB", store.ImageStorageLimitBytes()) + } assertConfigValue(t, got, "log_retention_days", 30) assertConfigValue(t, got, "auto_remove_invalid_accounts", false) assertConfigValue(t, got, "auto_remove_rate_limited_accounts", true) @@ -312,6 +329,7 @@ func TestStoreUpdateRefreshesEnvFileBackedRuntimeSettings(t *testing.T) { "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT": "3", "CHATGPT2API_USER_DEFAULT_RPM_LIMIT": "45", "CHATGPT2API_IMAGE_RETENTION_DAYS": "12", + "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB": "1024", "CHATGPT2API_LOG_RETENTION_DAYS": "30", "CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS": "false", "CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS": "true", @@ -323,28 +341,246 @@ func TestStoreUpdateRefreshesEnvFileBackedRuntimeSettings(t *testing.T) { } } -func TestStoreKeepsDifferentExternalEnvironmentOverride(t *testing.T) { +func TestStoreEnvFileValueWinsOverStaleProcessEnvironment(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".env"), []byte(strings.Join([]string{ - "CHATGPT2API_BASE_URL=https://file.example", + "CHATGPT2API_REGISTRATION_ENABLED=false", "", }, "\n")), 0o644); err != nil { t.Fatalf("write .env: %v", err) } t.Setenv("CHATGPT2API_ROOT", root) - t.Setenv("CHATGPT2API_BASE_URL", "https://external.example") + t.Setenv("CHATGPT2API_REGISTRATION_ENABLED", "true") + + store, err := NewStore() + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + if store.RegistrationEnabled() { + t.Fatal("RegistrationEnabled() used stale process environment instead of .env") + } + got, err := store.Update(map[string]any{"registration_enabled": false}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + assertConfigValue(t, got, "registration_enabled", false) + if gotEnv := os.Getenv("CHATGPT2API_REGISTRATION_ENABLED"); gotEnv != "false" { + t.Fatalf("CHATGPT2API_REGISTRATION_ENABLED = %q, want saved false", gotEnv) + } +} + +func TestStoreUpdateOverridesEnvOnlyRuntimeSetting(t *testing.T) { + root := t.TempDir() + t.Setenv("CHATGPT2API_ROOT", root) + t.Setenv("CHATGPT2API_REGISTRATION_ENABLED", "true") + + store, err := NewStore() + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + if !store.RegistrationEnabled() { + t.Fatal("RegistrationEnabled() should seed from env-only setting") + } + got, err := store.Update(map[string]any{"registration_enabled": false}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + assertConfigValue(t, got, "registration_enabled", false) + if store.RegistrationEnabled() { + t.Fatal("RegistrationEnabled() stayed enabled after saving false") + } + if gotEnv := os.Getenv("CHATGPT2API_REGISTRATION_ENABLED"); gotEnv != "false" { + t.Fatalf("CHATGPT2API_REGISTRATION_ENABLED = %q, want saved false", gotEnv) + } + envData, err := os.ReadFile(filepath.Join(root, ".env")) + if err != nil { + t.Fatalf("read .env: %v", err) + } + if !strings.Contains(string(envData), "CHATGPT2API_REGISTRATION_ENABLED=false") { + t.Fatalf(".env missing saved registration setting:\n%s", string(envData)) + } +} + +func TestStoreUpdateOverridesEnvOnlyRuntimeSettings(t *testing.T) { + root := t.TempDir() + t.Setenv("CHATGPT2API_ROOT", root) + unsetLinuxDoEnv(t) + for key, value := range map[string]string{ + "CHATGPT2API_BASE_URL": "https://old.example/root", + "CHATGPT2API_PROXY": "http://127.0.0.1:8080", + "CHATGPT2API_REFRESH_ACCOUNT_INTERVAL_MINUTE": "5", + "CHATGPT2API_IMAGE_TASK_TIMEOUT_SECONDS": "300", + "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT": "2", + "CHATGPT2API_USER_DEFAULT_RPM_LIMIT": "30", + "CHATGPT2API_DEFAULT_BILLING_TYPE": "standard", + "CHATGPT2API_DEFAULT_STANDARD_BALANCE": "1", + "CHATGPT2API_DEFAULT_SUBSCRIPTION_QUOTA": "2", + "CHATGPT2API_DEFAULT_SUBSCRIPTION_PERIOD": "monthly", + "CHATGPT2API_IMAGE_RETENTION_DAYS": "30", + "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB": "2048", + "CHATGPT2API_LOG_RETENTION_DAYS": "7", + "CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS": "true", + "CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS": "false", + "CHATGPT2API_LOG_LEVELS": "warning,error", + "CHATGPT2API_REGISTRATION_ENABLED": "true", + "CHATGPT2API_LOGIN_PAGE_IMAGE_URL": "https://old.example/login.png", + "CHATGPT2API_LOGIN_PAGE_IMAGE_MODE": "contain", + "CHATGPT2API_LOGIN_PAGE_IMAGE_ZOOM": "1", + "CHATGPT2API_LOGIN_PAGE_IMAGE_POSITION_X": "50", + "CHATGPT2API_LOGIN_PAGE_IMAGE_POSITION_Y": "50", + } { + t.Setenv(key, value) + } + + store, err := NewStore() + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + got, err := store.Update(map[string]any{ + "base_url": "https://new.example/root/", + "proxy": "http://127.0.0.1:9090", + "refresh_account_interval_minute": 9, + "image_task_timeout_seconds": 480, + "user_default_concurrent_limit": 3, + "user_default_rpm_limit": 45, + "default_billing_type": "subscription", + "default_standard_balance": 11, + "default_subscription_quota": 22, + "default_subscription_period": "weekly", + "image_retention_days": 12, + "image_storage_limit_mb": 1024, + "log_retention_days": 30, + "auto_remove_invalid_accounts": false, + "auto_remove_rate_limited_accounts": true, + "log_levels": []any{"debug", "info"}, + "registration_enabled": false, + "login_page_image_url": "https://new.example/login.png", + "login_page_image_mode": "cover", + "login_page_image_zoom": 2, + "login_page_image_position_x": 25, + "login_page_image_position_y": 75, + }) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + assertConfigValue(t, got, "base_url", "https://new.example/root") + assertConfigValue(t, got, "proxy", "http://127.0.0.1:9090") + assertConfigValue(t, got, "refresh_account_interval_minute", 9) + assertConfigValue(t, got, "image_task_timeout_seconds", 480) + assertConfigValue(t, got, "user_default_concurrent_limit", 3) + assertConfigValue(t, got, "user_default_rpm_limit", 45) + assertConfigValue(t, got, "default_billing_type", "subscription") + assertConfigValue(t, got, "default_standard_balance", 11) + assertConfigValue(t, got, "default_subscription_quota", 22) + assertConfigValue(t, got, "default_subscription_period", "weekly") + assertConfigValue(t, got, "image_retention_days", 12) + assertConfigValue(t, got, "image_storage_limit_mb", 1024) + assertConfigValue(t, got, "log_retention_days", 30) + assertConfigValue(t, got, "auto_remove_invalid_accounts", false) + assertConfigValue(t, got, "auto_remove_rate_limited_accounts", true) + assertConfigValue(t, got, "registration_enabled", false) + assertConfigValue(t, got, "login_page_image_url", "https://new.example/login.png") + assertConfigValue(t, got, "login_page_image_mode", "cover") + assertConfigValue(t, got, "login_page_image_zoom", float64(2)) + assertConfigValue(t, got, "login_page_image_position_x", float64(25)) + assertConfigValue(t, got, "login_page_image_position_y", float64(75)) + if levels := strings.Join(store.LogLevels(), ","); levels != "debug,info" { + t.Fatalf("LogLevels() = %q, want debug,info", levels) + } + + for key, want := range map[string]string{ + "CHATGPT2API_BASE_URL": "https://new.example/root/", + "CHATGPT2API_PROXY": "http://127.0.0.1:9090", + "CHATGPT2API_REFRESH_ACCOUNT_INTERVAL_MINUTE": "9", + "CHATGPT2API_IMAGE_TASK_TIMEOUT_SECONDS": "480", + "CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT": "3", + "CHATGPT2API_USER_DEFAULT_RPM_LIMIT": "45", + "CHATGPT2API_DEFAULT_BILLING_TYPE": "subscription", + "CHATGPT2API_DEFAULT_STANDARD_BALANCE": "11", + "CHATGPT2API_DEFAULT_SUBSCRIPTION_QUOTA": "22", + "CHATGPT2API_DEFAULT_SUBSCRIPTION_PERIOD": "weekly", + "CHATGPT2API_IMAGE_RETENTION_DAYS": "12", + "CHATGPT2API_IMAGE_STORAGE_LIMIT_MB": "1024", + "CHATGPT2API_LOG_RETENTION_DAYS": "30", + "CHATGPT2API_AUTO_REMOVE_INVALID_ACCOUNTS": "false", + "CHATGPT2API_AUTO_REMOVE_RATE_LIMITED_ACCOUNTS": "true", + "CHATGPT2API_LOG_LEVELS": "debug,info", + "CHATGPT2API_REGISTRATION_ENABLED": "false", + "CHATGPT2API_LOGIN_PAGE_IMAGE_URL": "https://new.example/login.png", + "CHATGPT2API_LOGIN_PAGE_IMAGE_MODE": "cover", + "CHATGPT2API_LOGIN_PAGE_IMAGE_ZOOM": "2", + "CHATGPT2API_LOGIN_PAGE_IMAGE_POSITION_X": "25", + "CHATGPT2API_LOGIN_PAGE_IMAGE_POSITION_Y": "75", + } { + if gotEnv := os.Getenv(key); gotEnv != want { + t.Fatalf("%s = %q, want %q", key, gotEnv, want) + } + } +} + +func TestStoreUpdateOverridesLinuxDoEnvOnlyRuntimeSettings(t *testing.T) { + root := t.TempDir() + t.Setenv("CHATGPT2API_ROOT", root) + t.Setenv("CHATGPT2API_BASE_URL", "https://old.example") + t.Setenv("CHATGPT2API_LINUXDO_ENABLED", "true") + t.Setenv("CHATGPT2API_LINUXDO_CLIENT_ID", "old-client") + t.Setenv("CHATGPT2API_LINUXDO_CLIENT_SECRET", "old-secret") + t.Setenv("CHATGPT2API_LINUXDO_REDIRECT_URL", "https://old.example/auth/linuxdo/oauth/callback") + t.Setenv("CHATGPT2API_LINUXDO_FRONTEND_REDIRECT_URL", "/old/callback") + for _, key := range []string{ + "CHATGPT2API_LINUXDO_AUTHORIZE_URL", + "CHATGPT2API_LINUXDO_TOKEN_URL", + "CHATGPT2API_LINUXDO_USERINFO_URL", + "CHATGPT2API_LINUXDO_SCOPES", + "CHATGPT2API_LINUXDO_TOKEN_AUTH_METHOD", + "CHATGPT2API_LINUXDO_USE_PKCE", + "CHATGPT2API_LINUXDO_USERINFO_EMAIL_PATH", + "CHATGPT2API_LINUXDO_USERINFO_ID_PATH", + "CHATGPT2API_LINUXDO_USERINFO_USERNAME_PATH", + } { + unsetEnv(t, key) + } store, err := NewStore() if err != nil { t.Fatalf("NewStore() error = %v", err) } - got, err := store.Update(map[string]any{"base_url": "https://saved.example"}) + got, err := store.Update(map[string]any{ + "base_url": "https://new.example", + "linuxdo_enabled": false, + "linuxdo_client_id": "new-client", + "linuxdo_client_secret": "new-secret", + "linuxdo_redirect_url": "https://new.example/auth/linuxdo/oauth/callback", + "linuxdo_frontend_redirect_url": "/auth/linuxdo/callback", + }) if err != nil { t.Fatalf("Update() error = %v", err) } - assertConfigValue(t, got, "base_url", "https://external.example") - if gotEnv := os.Getenv("CHATGPT2API_BASE_URL"); gotEnv != "https://external.example" { - t.Fatalf("CHATGPT2API_BASE_URL = %q, want external override unchanged", gotEnv) + assertConfigValue(t, got, "linuxdo_enabled", false) + assertConfigValue(t, got, "linuxdo_client_id", "new-client") + assertConfigValue(t, got, "linuxdo_redirect_url", "https://new.example/auth/linuxdo/oauth/callback") + assertConfigValue(t, got, "linuxdo_frontend_redirect_url", "/auth/linuxdo/callback") + if got["linuxdo_client_secret_configured"] != true { + t.Fatalf("linuxdo_client_secret_configured = %#v, want true", got["linuxdo_client_secret_configured"]) + } + linuxdo := store.LinuxDoOAuth() + if linuxdo.Enabled || linuxdo.ClientID != "new-client" || linuxdo.ClientSecret != "new-secret" || + linuxdo.RedirectURL != "https://new.example/auth/linuxdo/oauth/callback" || + linuxdo.FrontendRedirectURL != "/auth/linuxdo/callback" { + t.Fatalf("LinuxDoOAuth() = %#v", linuxdo) + } + for key, want := range map[string]string{ + "CHATGPT2API_BASE_URL": "https://new.example", + "CHATGPT2API_LINUXDO_ENABLED": "false", + "CHATGPT2API_LINUXDO_CLIENT_ID": "new-client", + "CHATGPT2API_LINUXDO_CLIENT_SECRET": "new-secret", + "CHATGPT2API_LINUXDO_REDIRECT_URL": "https://new.example/auth/linuxdo/oauth/callback", + "CHATGPT2API_LINUXDO_FRONTEND_REDIRECT_URL": "/auth/linuxdo/callback", + } { + if gotEnv := os.Getenv(key); gotEnv != want { + t.Fatalf("%s = %q, want %q", key, gotEnv, want) + } } } diff --git a/internal/httpapi/app.go b/internal/httpapi/app.go index a5793fafe..6d7a069e3 100644 --- a/internal/httpapi/app.go +++ b/internal/httpapi/app.go @@ -3,6 +3,7 @@ package httpapi import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -18,6 +19,7 @@ import ( "path" "path/filepath" "strings" + "sync" "time" "chatgpt2api/internal/config" @@ -41,6 +43,7 @@ type App struct { config *config.Store auth *service.AuthService accounts *service.AccountService + billing *service.BillingService logs *service.LogService logger *service.Logger proxy *service.ProxyService @@ -68,7 +71,7 @@ func NewApp() (*App, error) { return nil, err } ctx, cancel := context.WithCancel(context.Background()) - logs := service.NewLogService(cfg.DataDir, storageBackend) + logs := service.NewLogService(storageBackend) logger, err := service.NewLogger(cfg.DataDir, cfg.LogLevels) if err != nil { cancel() @@ -77,6 +80,10 @@ func NewApp() (*App, error) { proxy := service.NewProxyService(cfg) accounts := service.NewAccountService(storageBackend, cfg, proxy, logs) auth := service.NewAuthService(storageBackend) + billing := service.NewBillingService(storageBackend, cfg) + auth.SetUserCreatedHook(func(userID string) { + billing.InitializeUserDefaults(userID) + }) bootstrap, err := auth.EnsureBootstrapAdmin(cfg.AdminUsername(), cfg.AdminPassword()) if err != nil { cancel() @@ -87,12 +94,13 @@ func NewApp() (*App, error) { logger.Warning("bootstrap admin password generated", "username", bootstrap.Username) } documentStore, _ := storageBackend.(storage.JSONDocumentBackend) - engine := &protocol.Engine{Accounts: accounts, Config: cfg, Storage: documentStore, Proxy: proxy, Logger: logger} - app := &App{config: cfg, auth: auth, accounts: accounts, logs: logs, logger: logger, proxy: proxy, engine: engine, images: service.NewImageService(cfg, storageBackend), announce: service.NewAnnouncementService(cfg.DataDir, storageBackend), prompts: service.NewPromptFavoriteService(cfg.DataDir, storageBackend), cpa: service.NewCPAConfig(cfg.DataDir, storageBackend), sub2: service.NewSub2APIConfig(cfg.DataDir, storageBackend), update: newUpdateService(cfg), cancel: cancel} + imageSessions := service.NewImageConversationSessionService(filepath.Join(cfg.DataDir, "image_conversation_sessions.json"), storageBackend) + engine := &protocol.Engine{Accounts: accounts, Config: cfg, Storage: documentStore, Proxy: proxy, Logger: logger, ImageConversationSessions: imageSessions} + app := &App{config: cfg, auth: auth, accounts: accounts, billing: billing, logs: logs, logger: logger, proxy: proxy, engine: engine, images: service.NewImageService(cfg, storageBackend), announce: service.NewAnnouncementService(storageBackend), prompts: service.NewPromptFavoriteService(storageBackend), cpa: service.NewCPAConfig(storageBackend), sub2: service.NewSub2APIConfig(storageBackend), update: newUpdateService(cfg), cancel: cancel} app.cpaImport = service.NewCPAImportService(app.cpa, accounts, proxy) app.sub2Import = service.NewSub2APIService(app.sub2, accounts) - app.register = service.NewRegisterService(cfg.DataDir, accounts, storageBackend) - app.tasks = service.NewStoredImageTaskService(filepath.Join(cfg.DataDir, "image_tasks.json"), storageBackend, + app.register = service.NewRegisterService(accounts, storageBackend) + app.tasks = service.NewStoredImageTaskService(storageBackend, func(ctx context.Context, identity service.Identity, payload map[string]any) (map[string]any, error) { return app.runLoggedImageTask(ctx, identity, payload, "/api/creation-tasks/image-generations", "文生图", func(ctx context.Context, payload map[string]any) (map[string]any, error) { result, _, err := engine.HandleImageGenerations(ctx, payload) @@ -113,11 +121,16 @@ func NewApp() (*App, error) { cfg.UserDefaultConcurrentLimit, cfg.UserDefaultRPMLimit, ) + app.tasks.SetBillingService(billing) app.tasks.SetTaskTimeoutGetter(func() time.Duration { return time.Duration(app.config.ImageTaskTimeoutSeconds()) * time.Second }) accounts.StartLimitedWatcher(ctx, time.Duration(cfg.RefreshAccountIntervalMinute())*time.Minute) - cfg.CleanupOldImages() + logs.StartRetentionCleaner(ctx, cfg.LogRetentionDays, 24*time.Hour, logger) + _, _ = app.images.CleanupStorage(service.ImageStorageCleanupOptions{ + RetentionDays: cfg.ImageRetentionDays(), + MaxBytes: cfg.ImageStorageLimitBytes(), + }) return app, nil } @@ -138,6 +151,13 @@ func (a *App) Close() { if a.logger != nil { _ = a.logger.Close() } + if a.config != nil { + if backend, err := a.config.StorageBackend(); err == nil { + if closer, ok := backend.(interface{ Close() error }); ok { + _ = closer.Close() + } + } + } } func (a *App) Logger() *service.Logger { @@ -150,7 +170,7 @@ func (a *App) handleModels(w http.ResponseWriter, r *http.Request) { return } result, err := a.engine.ListModels(r.Context()) - a.writeProtocol(w, r, result, nil, err, "openai", "/v1/models", "models", identity, "模型列表", service.ImageVisibilityPrivate) + a.writeProtocol(w, r, result, nil, err, "openai", "/v1/models", "models", identity, "模型列表", service.ImageVisibilityPrivate, service.BillingReference{}) } func (a *App) handleImageGenerations(w http.ResponseWriter, r *http.Request) { @@ -166,6 +186,7 @@ func (a *App) handleImageGenerations(w http.ResponseWriter, r *http.Request) { body["owner_id"] = identityScope(identity) body["owner_name"] = identityDisplayName(identity) body["base_url"] = a.resolveImageBaseURL(r) + a.attachFallbackReferenceImage(identity, body) a.attachCreationTaskLimiter(body, identity) visibility, err := service.NormalizeImageVisibility(util.Clean(body["visibility"])) if err != nil { @@ -173,8 +194,14 @@ func (a *App) handleImageGenerations(w http.ResponseWriter, r *http.Request) { return } model := firstNonEmpty(util.Clean(body["model"]), util.ImageModelAuto) + if err := a.checkProtocolBilling(identity, protocolBillableUnits("/v1/images/generations", body)); err != nil { + a.writeProtocol(w, r, nil, nil, err, "openai", "/v1/images/generations", model, identity, "文生图", visibility, service.BillingReference{}) + return + } + billingRef := a.protocolBillingReference(identity, "/v1/images/generations", model) + a.attachProtocolBillingCharger(body, identity, billingRef) result, stream, err := a.engine.HandleImageGenerations(r.Context(), body) - a.writeProtocol(w, r, result, stream, err, "openai", "/v1/images/generations", model, identity, "文生图", visibility) + a.writeProtocol(w, r, result, stream, err, "openai", "/v1/images/generations", model, identity, "文生图", visibility, billingRef, body) } func (a *App) handleImageEdits(w http.ResponseWriter, r *http.Request) { @@ -198,15 +225,23 @@ func (a *App) handleImageEdits(w http.ResponseWriter, r *http.Request) { body["owner_id"] = identityScope(identity) body["owner_name"] = identityDisplayName(identity) body["base_url"] = a.resolveImageBaseURL(r) + a.attachFallbackReferenceImage(identity, body) a.attachCreationTaskLimiter(body, identity) + body["images"] = images visibility, err := service.NormalizeImageVisibility(util.Clean(body["visibility"])) if err != nil { util.WriteError(w, http.StatusBadRequest, err.Error()) return } model := firstNonEmpty(util.Clean(body["model"]), util.ImageModelAuto) + if err := a.checkProtocolBilling(identity, protocolBillableUnits("/v1/images/edits", body)); err != nil { + a.writeProtocol(w, r, nil, nil, err, "openai", "/v1/images/edits", model, identity, "图生图", visibility, service.BillingReference{}) + return + } + billingRef := a.protocolBillingReference(identity, "/v1/images/edits", model) + a.attachProtocolBillingCharger(body, identity, billingRef) result, stream, err := a.engine.HandleImageEdits(r.Context(), body, images) - a.writeProtocol(w, r, result, stream, err, "openai", "/v1/images/edits", model, identity, "图生图", visibility) + a.writeProtocol(w, r, result, stream, err, "openai", "/v1/images/edits", model, identity, "图生图", visibility, billingRef, body) } func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) { @@ -223,8 +258,14 @@ func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) { body["owner_name"] = identityDisplayName(identity) a.attachCreationTaskLimiter(body, identity) model := firstNonEmpty(util.Clean(body["model"]), "auto") + if err := a.checkProtocolBilling(identity, protocolBillableUnits("/v1/chat/completions", body)); err != nil { + a.writeProtocol(w, r, nil, nil, err, "openai", "/v1/chat/completions", model, identity, "文本生成", service.ImageVisibilityPrivate, service.BillingReference{}) + return + } + billingRef := a.protocolBillingReference(identity, "/v1/chat/completions", model) + a.attachProtocolBillingCharger(body, identity, billingRef) result, stream, err := a.engine.HandleChatCompletions(r.Context(), body) - a.writeProtocol(w, r, result, stream, err, "openai", "/v1/chat/completions", model, identity, "文本生成", service.ImageVisibilityPrivate) + a.writeProtocol(w, r, result, stream, err, "openai", "/v1/chat/completions", model, identity, "文本生成", service.ImageVisibilityPrivate, billingRef) } func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) { @@ -241,8 +282,14 @@ func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) { body["owner_name"] = identityDisplayName(identity) a.attachCreationTaskLimiter(body, identity) model := firstNonEmpty(util.Clean(body["model"]), "auto") + if err := a.checkProtocolBilling(identity, protocolBillableUnits("/v1/responses", body)); err != nil { + a.writeProtocol(w, r, nil, nil, err, "openai", "/v1/responses", model, identity, "Responses", service.ImageVisibilityPrivate, service.BillingReference{}) + return + } + billingRef := a.protocolBillingReference(identity, "/v1/responses", model) + a.attachProtocolBillingCharger(body, identity, billingRef) result, stream, err := a.engine.HandleResponsesScoped(r.Context(), body, identityScope(identity)) - a.writeProtocol(w, r, result, stream, err, "openai", "/v1/responses", model, identity, "Responses", service.ImageVisibilityPrivate) + a.writeProtocol(w, r, result, stream, err, "openai", "/v1/responses", model, identity, "Responses", service.ImageVisibilityPrivate, billingRef) } func (a *App) handleMessages(w http.ResponseWriter, r *http.Request) { @@ -261,20 +308,23 @@ func (a *App) handleMessages(w http.ResponseWriter, r *http.Request) { } model := firstNonEmpty(util.Clean(body["model"]), "auto") result, stream, err := a.engine.HandleMessages(r.Context(), body) - a.writeProtocol(w, r, result, stream, err, "anthropic", "/v1/messages", model, identity, "Messages", service.ImageVisibilityPrivate) + a.writeProtocol(w, r, result, stream, err, "anthropic", "/v1/messages", model, identity, "Messages", service.ImageVisibilityPrivate, service.BillingReference{}) } -func (a *App) writeProtocol(w http.ResponseWriter, r *http.Request, result map[string]any, stream *protocol.StreamResult, err error, sseKind, endpoint, model string, identity service.Identity, summary, visibility string) { +func (a *App) writeProtocol(w http.ResponseWriter, r *http.Request, result map[string]any, stream *protocol.StreamResult, err error, sseKind, endpoint, model string, identity service.Identity, summary, visibility string, billingRef service.BillingReference, imagePayloads ...map[string]any) { start := time.Now() + requestCapture := requestAuditCapture(r.Context()) if err != nil { - a.logCall(identity, summary, r.Method, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), nil) + a.logCall(identity, summary, r.Method, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), nil, requestCapture) + markRequestBusinessLogged(r) a.writeProtocolError(w, err) return } if stream == nil { urls := collectURLs(result) - a.recordGeneratedImages(identity, urls, visibility) - a.logCall(identity, summary, r.Method, endpoint, model, start, "success", http.StatusOK, "", urls) + a.recordProtocolGeneratedImages(identity, urls, visibility, imagePayloads...) + a.logCall(identity, summary, r.Method, endpoint, model, start, "success", http.StatusOK, "", urls, requestCapture) + markRequestBusinessLogged(r) util.WriteJSON(w, http.StatusOK, result) return } @@ -293,14 +343,16 @@ func (a *App) writeProtocol(w http.ResponseWriter, r *http.Request, result map[s } } if err := <-stream.Err; err != nil { - a.recordGeneratedImages(identity, urls, visibility) - a.logCall(identity, summary, r.Method, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls) + a.recordProtocolGeneratedImages(identity, urls, visibility, imagePayloads...) + a.logCall(identity, summary, r.Method, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls, requestCapture) + markRequestBusinessLogged(r) fmt.Fprintf(w, "event: error\n") fmt.Fprintf(w, "data: %s\n\n", jsonString(map[string]any{"type": "error", "error": map[string]any{"type": fmt.Sprintf("%T", err), "message": err.Error()}})) return } - a.recordGeneratedImages(identity, urls, visibility) - a.logCall(identity, summary, r.Method, endpoint, model, start, "success", http.StatusOK, "", urls) + a.recordProtocolGeneratedImages(identity, urls, visibility, imagePayloads...) + a.logCall(identity, summary, r.Method, endpoint, model, start, "success", http.StatusOK, "", urls, requestCapture) + markRequestBusinessLogged(r) return } fmt.Fprint(w, ": stream-open\n\n") @@ -316,12 +368,14 @@ func (a *App) writeProtocol(w http.ResponseWriter, r *http.Request, result map[s } } if err := <-stream.Err; err != nil { - a.recordGeneratedImages(identity, urls, visibility) - a.logCall(identity, summary, r.Method, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls) + a.recordProtocolGeneratedImages(identity, urls, visibility, imagePayloads...) + a.logCall(identity, summary, r.Method, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls, requestCapture) + markRequestBusinessLogged(r) fmt.Fprintf(w, "data: %s\n\n", jsonString(openAIErrorForStream(err))) } else { - a.recordGeneratedImages(identity, urls, visibility) - a.logCall(identity, summary, r.Method, endpoint, model, start, "success", http.StatusOK, "", urls) + a.recordProtocolGeneratedImages(identity, urls, visibility, imagePayloads...) + a.logCall(identity, summary, r.Method, endpoint, model, start, "success", http.StatusOK, "", urls, requestCapture) + markRequestBusinessLogged(r) } fmt.Fprint(w, "data: [DONE]\n\n") } @@ -331,6 +385,10 @@ func protocolErrorHTTPStatus(err error) int { if errors.As(err, &httpErr) { return httpErr.Status } + var billingErr service.BillingLimitError + if errors.As(err, &billingErr) { + return http.StatusTooManyRequests + } var imageErr *protocol.ImageGenerationError if errors.As(err, &imageErr) { return imageErr.StatusCode @@ -348,6 +406,11 @@ func (a *App) writeProtocolError(w http.ResponseWriter, err error) { util.WriteError(w, httpErr.Status, httpErr.Message) return } + var billingErr service.BillingLimitError + if errors.As(err, &billingErr) { + util.WriteJSON(w, http.StatusTooManyRequests, billingErr.OpenAIError()) + return + } var imageErr *protocol.ImageGenerationError if errors.As(err, &imageErr) { util.WriteJSON(w, imageErr.StatusCode, imageErr.OpenAIError()) @@ -389,7 +452,7 @@ func (a *App) handleSession(w http.ResponseWriter, r *http.Request) { func (a *App) handleAccountRegister(w http.ResponseWriter, r *http.Request) { if !a.config.RegistrationEnabled() { - util.WriteError(w, http.StatusForbidden, "registration is disabled") + util.WriteError(w, http.StatusForbidden, "已关闭注册通道") return } body, err := readJSONMap(r) @@ -430,6 +493,8 @@ func (a *App) writeLoginResponse(w http.ResponseWriter, identity service.Identit "credential_id": identity.CredentialID, "credential_name": identity.CredentialName, "creation_concurrent_limit": a.identityCreationConcurrentLimit(identity), + "creation_rpm_limit": a.identityCreationRPMLimit(identity), + "billing": a.identityBillingState(identity), "menu_paths": permissions.MenuPaths, "api_permissions": permissions.APIPermissions, "menus": service.FilterMenuPermissions(permissions.MenuPaths), @@ -447,6 +512,31 @@ func (a *App) identityCreationConcurrentLimit(identity service.Identity) int { return a.config.UserDefaultConcurrentLimit() } +func (a *App) identityCreationRPMLimit(identity service.Identity) int { + if identity.Role != service.AuthRoleUser { + return 0 + } + return a.config.UserDefaultRPMLimit() +} + +func (a *App) identityBillingState(identity service.Identity) map[string]any { + if identity.Role != service.AuthRoleUser { + return map[string]any{ + "type": service.BillingTypeStandard, + "unit": service.BillingUnitImage, + "unlimited": true, + "available": 0, + "standard": nil, + "subscription": nil, + "limit_state": "unlimited", + } + } + if a == nil || a.billing == nil { + return nil + } + return a.billing.Get(identityScope(identity)) +} + func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) { if _, ok := a.requireIdentity(w, r, ""); !ok { return @@ -710,11 +800,16 @@ func (a *App) handleImageVisibility(w http.ResponseWriter, r *http.Request) { return } visibility := util.Clean(body["visibility"]) + sharePromptParams := util.ToBool(body["share_prompt_parameters"]) + shareReferences := sharePromptParams && util.ToBool(body["share_reference_images"]) scope := service.ImageAccessScope{OwnerID: identityScope(identity)} if identity.Role == service.AuthRoleAdmin { scope = service.ImageAccessScope{All: true} } - item, err := a.images.UpdateImageVisibility(path, visibility, scope) + item, err := a.images.UpdateImageVisibility(path, visibility, scope, service.ImageVisibilityUpdateOptions{ + SharePromptParams: sharePromptParams, + ShareReferences: shareReferences, + }) if err != nil { status := http.StatusBadRequest if err.Error() == "image not found" { @@ -744,6 +839,42 @@ func (a *App) handleImageFile(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, ref.Path) } +func (a *App) handleImageReferenceFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + rel, err := imageReferenceFileRequestPath(r) + if err != nil { + http.NotFound(w, r) + return + } + ref, err := a.images.ImageReferenceFileAccess(rel) + if err != nil { + http.NotFound(w, r) + return + } + if ref.Visibility == service.ImageVisibilityPublic && ref.Shared { + if ref.ContentType != "" { + w.Header().Set("Content-Type", ref.ContentType) + } + http.ServeFile(w, r, ref.Path) + return + } + identity, ok := a.imageRequestIdentity(w, r) + if !ok { + return + } + if identity.Role != service.AuthRoleAdmin && (ref.OwnerID == "" || ref.OwnerID != identityScope(identity)) { + http.NotFound(w, r) + return + } + if ref.ContentType != "" { + w.Header().Set("Content-Type", ref.ContentType) + } + http.ServeFile(w, r, ref.Path) +} + func (a *App) authorizeImageFileRequest(w http.ResponseWriter, r *http.Request, rel string) (service.ImageFileAccess, bool) { ref, err := a.images.ImageFileAccess(rel, service.ImageAccessScope{All: true}) if err != nil { @@ -809,6 +940,18 @@ func imageFileRequestPath(r *http.Request) (string, error) { return rel, nil } +func imageReferenceFileRequestPath(r *http.Request) (string, error) { + raw := strings.TrimPrefix(r.URL.EscapedPath(), "/image-references/") + if raw == "" || raw == r.URL.EscapedPath() { + return "", errors.New("invalid image path") + } + rel, err := url.PathUnescape(raw) + if err != nil { + return "", err + } + return rel, nil +} + func imageThumbnailRequestPath(r *http.Request) (string, error) { raw := strings.TrimPrefix(r.URL.EscapedPath(), "/image-thumbnails/") if raw == "" || raw == r.URL.EscapedPath() { @@ -830,8 +973,12 @@ func (a *App) handleLogs(w http.ResponseWriter, r *http.Request) { util.WriteError(w, http.StatusBadRequest, err.Error()) return } + if query.View == "" { + query.View = a.config.DefaultLogView() + } + query.View = service.NormalizeLogView(query.View, a.config.DefaultLogView()) items := a.logs.Search(query) - util.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items), "page_size": normalizedHTTPLogPageSize(query.Limit)}) + util.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items), "page_size": normalizedHTTPLogPageSize(query.Limit), "view": query.View}) } func (a *App) handleLogGovernance(w http.ResponseWriter, r *http.Request) { @@ -862,6 +1009,62 @@ func (a *App) handleLogGovernance(w http.ResponseWriter, r *http.Request) { } } +func (a *App) handleImageStorageGovernance(w http.ResponseWriter, r *http.Request) { + if _, ok := a.requireIdentity(w, r, ""); !ok { + return + } + switch r.Method { + case http.MethodGet: + util.WriteJSON(w, http.StatusOK, map[string]any{"governance": a.images.StorageGovernance()}) + case http.MethodPost: + body, err := readJSONMap(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, "invalid json body") + return + } + action := strings.TrimSpace(util.Clean(body["action"])) + options := service.ImageStorageCleanupOptions{ + IncludePublic: util.ToBool(body["include_public"]), + } + switch action { + case "retention": + options.RetentionDays = util.ToInt(body["retention_days"], a.config.ImageRetentionDays()) + case "quota": + options.MaxBytes = imageCleanupMaxBytes(body["max_bytes"], body["max_mb"], a.config.ImageStorageLimitBytes()) + case "thumbnails": + options.ClearThumbnails = true + case "all": + options.RetentionDays = util.ToInt(body["retention_days"], a.config.ImageRetentionDays()) + options.MaxBytes = imageCleanupMaxBytes(body["max_bytes"], body["max_mb"], a.config.ImageStorageLimitBytes()) + options.ClearThumbnails = util.ToBool(body["clear_thumbnails"]) + default: + util.WriteError(w, http.StatusBadRequest, "action must be retention, quota, thumbnails, or all") + return + } + result, err := a.images.CleanupStorage(options) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + util.WriteJSON(w, http.StatusOK, map[string]any{ + "cleanup": result, + "governance": a.images.StorageGovernance(), + }) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func imageCleanupMaxBytes(rawBytes, rawMB any, fallback int64) int64 { + if n := int64(util.ToInt(rawBytes, 0)); n > 0 { + return n + } + if mb := util.ToInt(rawMB, 0); mb > 0 { + return int64(mb) * 1024 * 1024 + } + return fallback +} + func (a *App) handleStorageInfo(w http.ResponseWriter, r *http.Request) { if _, ok := a.requireIdentity(w, r, ""); !ok { return @@ -1080,22 +1283,26 @@ func readMultipartImageBody(r *http.Request) (map[string]any, []protocol.Uploade return nil, nil, err } body := map[string]any{ - "client_task_id": firstForm(r.MultipartForm, "client_task_id"), - "prompt": firstForm(r.MultipartForm, "prompt"), - "model": firstNonEmpty(firstForm(r.MultipartForm, "model"), util.ImageModelAuto), - "n": util.ToInt(firstForm(r.MultipartForm, "n"), 1), - "size": firstForm(r.MultipartForm, "size"), - "quality": firstForm(r.MultipartForm, "quality"), - "background": firstForm(r.MultipartForm, "background"), - "moderation": firstForm(r.MultipartForm, "moderation"), - "style": firstForm(r.MultipartForm, "style"), - "partial_images": firstForm(r.MultipartForm, "partial_images"), - "input_image_mask": firstForm(r.MultipartForm, "input_image_mask"), - "output_format": firstForm(r.MultipartForm, "output_format"), - "output_compression": firstForm(r.MultipartForm, "output_compression"), - "visibility": firstForm(r.MultipartForm, "visibility"), - "response_format": firstNonEmpty(firstForm(r.MultipartForm, "response_format"), "b64_json"), - "stream": util.ToBool(firstForm(r.MultipartForm, "stream")), + "client_task_id": firstForm(r.MultipartForm, "client_task_id"), + "prompt": firstForm(r.MultipartForm, "prompt"), + "model": firstNonEmpty(firstForm(r.MultipartForm, "model"), util.ImageModelAuto), + "n": util.ToInt(firstForm(r.MultipartForm, "n"), 1), + "size": firstForm(r.MultipartForm, "size"), + "image_resolution": firstForm(r.MultipartForm, "image_resolution"), + "quality": firstForm(r.MultipartForm, "quality"), + "background": firstForm(r.MultipartForm, "background"), + "moderation": firstForm(r.MultipartForm, "moderation"), + "style": firstForm(r.MultipartForm, "style"), + "partial_images": firstForm(r.MultipartForm, "partial_images"), + "input_image_mask": firstForm(r.MultipartForm, "input_image_mask"), + "output_format": firstForm(r.MultipartForm, "output_format"), + "output_compression": firstForm(r.MultipartForm, "output_compression"), + "share_prompt_parameters": firstForm(r.MultipartForm, "share_prompt_parameters"), + "share_reference_images": firstForm(r.MultipartForm, "share_reference_images"), + "frontend_conversation_id": firstForm(r.MultipartForm, "frontend_conversation_id"), + "visibility": firstForm(r.MultipartForm, "visibility"), + "response_format": firstNonEmpty(firstForm(r.MultipartForm, "response_format"), "b64_json"), + "stream": util.ToBool(firstForm(r.MultipartForm, "stream")), } if rawMessages := strings.TrimSpace(firstForm(r.MultipartForm, "messages")); rawMessages != "" { var messages any @@ -1104,6 +1311,13 @@ func readMultipartImageBody(r *http.Request) (map[string]any, []protocol.Uploade } body["messages"] = messages } + if rawFallback := strings.TrimSpace(firstForm(r.MultipartForm, "fallback_reference_image")); rawFallback != "" { + var fallback any + if err := json.Unmarshal([]byte(rawFallback), &fallback); err != nil { + return nil, nil, fmt.Errorf("invalid fallback_reference_image") + } + body["fallback_reference_image"] = fallback + } var images []protocol.UploadedImage for _, field := range []string{"image", "image[]"} { for _, header := range r.MultipartForm.File[field] { @@ -1154,6 +1368,10 @@ func jsonString(v any) string { } func openAIErrorForStream(err error) map[string]any { + var billingErr service.BillingLimitError + if errors.As(err, &billingErr) { + return billingErr.OpenAIError() + } var imageErr *protocol.ImageGenerationError if errors.As(err, &imageErr) { return imageErr.OpenAIError() @@ -1161,7 +1379,7 @@ func openAIErrorForStream(err error) map[string]any { return map[string]any{"error": map[string]any{"message": err.Error(), "type": fmt.Sprintf("%T", err)}} } -func (a *App) logCall(identity service.Identity, summary, method, endpoint, model string, started time.Time, outcome string, status int, errText string, urls []string) { +func (a *App) logCall(identity service.Identity, summary, method, endpoint, model string, started time.Time, outcome string, status int, errText string, urls []string, requestCapture auditRequestCapture) { method = strings.ToUpper(strings.TrimSpace(method)) if status <= 0 { status = http.StatusOK @@ -1194,6 +1412,7 @@ func (a *App) logCall(identity service.Identity, summary, method, endpoint, mode if len(urls) > 0 { detail["urls"] = dedupe(urls) } + addAuditRequestDetail(detail, requestCapture) suffix := "调用完成" if outcome == "failed" { suffix = "调用失败" @@ -1202,7 +1421,16 @@ func (a *App) logCall(identity service.Identity, summary, method, endpoint, mode } func addIdentityLogDetail(detail map[string]any, identity service.Identity) { - if name := util.Clean(firstNonEmpty(identity.CredentialName, identity.Name)); name != "" { + kind := util.Clean(identity.Kind) + if kind != "" { + detail["auth_kind"] = kind + } + credentialName := util.Clean(identity.CredentialName) + if identity.Kind == service.AuthKindSession { + if credentialName != "" { + detail["session_name"] = credentialName + } + } else if name := util.Clean(firstNonEmpty(identity.CredentialName, identity.Name)); name != "" { detail["key_name"] = name } if role := util.Clean(identity.Role); role != "" { @@ -1219,6 +1447,64 @@ func addIdentityLogDetail(detail map[string]any, identity service.Identity) { } } +func payloadAuditCapture(payload map[string]any) auditRequestCapture { + args := cleanAuditPayloadMap(payload) + if len(args) == 0 { + return auditRequestCapture{} + } + return auditRequestCapture{args: service.SanitizeLogValue(args)} +} + +func cleanAuditPayloadMap(payload map[string]any) map[string]any { + out := make(map[string]any, len(payload)) + for key, value := range payload { + switch key { + case "owner_id", "owner_name", "base_url": + continue + } + if isInternalPayloadValue(value) { + continue + } + out[key] = cleanAuditPayloadValue(value) + } + return out +} + +func cleanAuditPayloadValue(value any) any { + switch x := value.(type) { + case []protocol.UploadedImage: + items := make([]map[string]any, 0, len(x)) + for _, image := range x { + items = append(items, map[string]any{ + "filename": image.Filename, + "content_type": image.ContentType, + "size_bytes": len(image.Data), + }) + } + return items + case protocol.UploadedImage: + return map[string]any{ + "filename": x.Filename, + "content_type": x.ContentType, + "size_bytes": len(x.Data), + } + default: + return value + } +} + +func isInternalPayloadValue(value any) bool { + if value == nil { + return false + } + switch value.(type) { + case func(context.Context, int) (func(), error), func([]map[string]any): + return true + default: + return false + } +} + func identityScope(identity service.Identity) string { if owner := util.Clean(identity.OwnerID); owner != "" { return owner @@ -1240,6 +1526,61 @@ func imageAccessScope(identity service.Identity) service.ImageAccessScope { return service.ImageAccessScope{OwnerID: identityScope(identity)} } +func (a *App) attachFallbackReferenceImage(identity service.Identity, payload map[string]any) { + if a == nil || a.images == nil || payload == nil || util.Clean(payload["fallback_reference_image_b64"]) != "" { + return + } + fallback := util.StringMap(payload["fallback_reference_image"]) + if len(fallback) == 0 { + return + } + if dataURL := fallbackReferenceDataURL(util.Clean(fallback["b64_json"])); dataURL != "" { + payload["fallback_reference_image_b64"] = dataURL + return + } + for _, key := range []string{"path", "url"} { + value := util.Clean(fallback[key]) + if value == "" { + continue + } + data, mimeType, err := a.images.ImageBytes(value, imageAccessScope(identity)) + if err != nil || len(data) == 0 { + continue + } + payload["fallback_reference_image_b64"] = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + return + } +} + +func fallbackReferenceDataURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + contentType := "" + dataPart := value + if strings.HasPrefix(value, "data:") { + header, data, ok := strings.Cut(value, ",") + if !ok { + return "" + } + dataPart = data + contentType = strings.TrimPrefix(strings.Split(header, ";")[0], "data:") + } + data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataPart)) + if err != nil || len(data) == 0 { + return "" + } + detected := http.DetectContentType(data) + if !strings.HasPrefix(contentType, "image/") { + contentType = detected + } + if !strings.HasPrefix(contentType, "image/") { + return "" + } + return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data) +} + func imageListAccessScope(identity service.Identity, value string) (service.ImageAccessScope, int, string) { switch strings.TrimSpace(value) { case "": @@ -1267,6 +1608,15 @@ func (a *App) recordGeneratedImages(identity service.Identity, urls []string, vi } ownerID := identityScope(identity) a.images.RecordGeneratedImages(urls, ownerID, identityDisplayName(identity), visibility) + a.cleanupImageStorage() +} + +func (a *App) recordProtocolGeneratedImages(identity service.Identity, urls []string, visibility string, payloads ...map[string]any) { + if len(payloads) > 0 && payloads[0] != nil { + a.recordGeneratedImagesForPayload(identity, urls, visibility, payloads[0]) + return + } + a.recordGeneratedImages(identity, urls, visibility) } func (a *App) recordGeneratedImagesForPayload(identity service.Identity, urls []string, visibility string, payload map[string]any) { @@ -1274,11 +1624,136 @@ func (a *App) recordGeneratedImagesForPayload(identity service.Identity, urls [] return } ownerID := identityScope(identity) + outputCompression, hasOutputCompression := imageOutputCompressionFromBody(payload["output_compression"]) + var outputCompressionPtr *int + if hasOutputCompression { + outputCompressionPtr = &outputCompression + } + var partialImagesPtr *int + if partialImages := util.ToInt(payload["partial_images"], 0); partialImages > 0 { + partialImagesPtr = &partialImages + } + sharePromptParams := util.ToBool(payload["share_prompt_parameters"]) a.images.RecordGeneratedImages(urls, ownerID, identityDisplayName(identity), visibility, service.GeneratedImageMetadata{ - ResolutionPreset: util.Clean(payload["image_resolution"]), - RequestedSize: util.Clean(payload["size"]), - OutputFormat: service.NormalizeImageOutputFormat(util.Clean(payload["output_format"])), + Prompt: util.Clean(payload["prompt"]), + Model: firstNonEmpty(util.Clean(payload["model"]), util.ImageModelAuto), + Quality: util.Clean(payload["quality"]), + ResolutionPreset: util.Clean(payload["image_resolution"]), + RequestedSize: util.Clean(payload["size"]), + OutputFormat: service.NormalizeImageOutputFormat(util.Clean(payload["output_format"])), + OutputCompression: outputCompressionPtr, + Background: util.Clean(payload["background"]), + Moderation: util.Clean(payload["moderation"]), + Style: util.Clean(payload["style"]), + PartialImages: partialImagesPtr, + InputImageMask: util.Clean(payload["input_image_mask"]), + ReferenceImages: imageReferenceMetadataFromPayload(payload), + SharePromptParams: sharePromptParams, + ShareReferences: sharePromptParams && util.ToBool(payload["share_reference_images"]), }) + a.cleanupImageStorage() +} + +func (a *App) cleanupImageStorage() { + if a == nil || a.images == nil || a.config == nil { + return + } + _, _ = a.images.CleanupStorage(service.ImageStorageCleanupOptions{ + RetentionDays: a.config.ImageRetentionDays(), + MaxBytes: a.config.ImageStorageLimitBytes(), + }) +} + +func imageReferenceMetadataFromPayload(payload map[string]any) []service.GeneratedImageReference { + if payload == nil { + return nil + } + images := uploadedImagesFromPayload(payload["images"]) + if len(images) == 0 { + images = protocol.ExtractChatContextImages(payload) + } + if len(images) == 0 { + return nil + } + refs := make([]service.GeneratedImageReference, 0, len(images)) + for _, image := range images { + if len(image.Data) == 0 { + continue + } + refs = append(refs, service.GeneratedImageReference{ + Filename: image.Filename, + ContentType: image.ContentType, + Data: append([]byte(nil), image.Data...), + }) + } + return refs +} + +func uploadedImagesFromPayload(value any) []protocol.UploadedImage { + switch images := value.(type) { + case []protocol.UploadedImage: + return images + case protocol.UploadedImage: + return []protocol.UploadedImage{images} + default: + return nil + } +} + +func (a *App) checkProtocolBilling(identity service.Identity, amount int) error { + if amount <= 0 || a == nil || a.billing == nil { + return nil + } + return a.billing.CheckAvailable(identity, amount) +} + +func (a *App) protocolBillingReference(identity service.Identity, endpoint, model string) service.BillingReference { + return service.BillingReference{ + Endpoint: endpoint, + Model: model, + RequestID: "req_" + util.NewHex(18), + CredentialID: identity.CredentialID, + CredentialName: identity.CredentialName, + } +} + +func (a *App) chargeProtocolBilling(identity service.Identity, consumed int, ref service.BillingReference) error { + if a == nil || a.billing == nil || consumed <= 0 { + return nil + } + return a.billing.Charge(identity, consumed, ref) +} + +// attachProtocolBillingCharger sets the per-image-output inline charge hook on +// the request body. The hook atomically deducts 1 billing unit before each +// image is persisted to disk, preventing gallery writes when balance/quota is +// insufficient. The chargeIndex counter ensures unique charge keys per output. +func (a *App) attachProtocolBillingCharger(body map[string]any, identity service.Identity, billingRef service.BillingReference) { + if a == nil || a.billing == nil || body == nil { + return + } + if identity.Role != service.AuthRoleUser { + return + } + var mu sync.Mutex + chargeIndex := 0 + body[protocol.ImageOutputChargePayloadKey] = func(index int) error { + mu.Lock() + idx := chargeIndex + chargeIndex++ + mu.Unlock() + ref := protocolChargeReference(billingRef, "inline", idx) + return a.billing.Charge(identity, 1, ref) + } +} + +func protocolChargeReference(ref service.BillingReference, scope string, index int) service.BillingReference { + if strings.TrimSpace(ref.ChargeKey) == "" && ref.Endpoint != "" { + keyID := firstNonEmpty(ref.RequestID, ref.TaskID, util.NewHex(12)) + ref.ChargeKey = strings.Join([]string{"protocol", ref.Endpoint, keyID, scope, fmt.Sprint(index)}, ":") + } + ref.OutputIndex = index + return ref } func (a *App) decorateImageList(payload map[string]any) { @@ -1323,22 +1798,24 @@ func (a *App) imageOwnerDisplayNames() map[string]string { func (a *App) runLoggedImageTask(ctx context.Context, identity service.Identity, payload map[string]any, endpoint, summary string, run func(context.Context, map[string]any) (map[string]any, error)) (map[string]any, error) { start := time.Now() + requestCapture := payloadAuditCapture(payload) payload["owner_id"] = identityScope(identity) payload["owner_name"] = identityDisplayName(identity) + a.attachFallbackReferenceImage(identity, payload) model := firstNonEmpty(util.Clean(payload["model"]), util.ImageModelAuto) result, err := run(ctx, payload) urls := collectURLs(result) a.recordGeneratedImagesForPayload(identity, urls, util.Clean(payload["visibility"]), payload) if err != nil { - a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls) + a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls, requestCapture) return result, err } if len(util.AsMapSlice(result["data"])) == 0 { message := firstNonEmpty(util.Clean(result["message"]), "image task returned no image data") - a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "failed", http.StatusBadGateway, message, urls) + a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "failed", http.StatusBadGateway, message, urls, requestCapture) return result, nil } - a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "success", http.StatusOK, "", urls) + a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "success", http.StatusOK, "", urls, requestCapture) return result, nil } @@ -1353,6 +1830,7 @@ func (a *App) attachCreationTaskLimiter(body map[string]any, identity service.Id func (a *App) runLoggedChatTask(ctx context.Context, identity service.Identity, payload map[string]any) (map[string]any, error) { start := time.Now() + requestCapture := payloadAuditCapture(payload) payload["owner_id"] = identityScope(identity) payload["owner_name"] = identityDisplayName(identity) payload["stream"] = false @@ -1362,16 +1840,16 @@ func (a *App) runLoggedChatTask(ctx context.Context, identity service.Identity, err = errors.New("chat task streaming is not supported") } if err != nil { - a.logCall(identity, "文本生成", http.MethodPost, "/api/creation-tasks/chat-completions", model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), nil) + a.logCall(identity, "文本生成", http.MethodPost, "/api/creation-tasks/chat-completions", model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), nil, requestCapture) return result, err } text := chatCompletionResultText(result) if text == "" { err = errors.New("模型没有返回文本内容") - a.logCall(identity, "文本生成", http.MethodPost, "/api/creation-tasks/chat-completions", model, start, "failed", http.StatusBadGateway, err.Error(), nil) + a.logCall(identity, "文本生成", http.MethodPost, "/api/creation-tasks/chat-completions", model, start, "failed", http.StatusBadGateway, err.Error(), nil, requestCapture) return result, err } - a.logCall(identity, "文本生成", http.MethodPost, "/api/creation-tasks/chat-completions", model, start, "success", http.StatusOK, "", nil) + a.logCall(identity, "文本生成", http.MethodPost, "/api/creation-tasks/chat-completions", model, start, "success", http.StatusOK, "", nil, requestCapture) return map[string]any{ "created": result["created"], "output_type": "text", @@ -1440,6 +1918,139 @@ func collectURLs(v any) []string { } } +func protocolBillableUnits(endpoint string, body map[string]any) int { + switch endpoint { + case "/v1/images/generations", "/v1/images/edits": + return normalizedProtocolImageCount(body["n"]) + case "/v1/chat/completions": + if protocol.IsImageChatRequest(body) { + return normalizedProtocolImageCount(body["n"]) + } + case "/v1/responses": + if protocol.HasResponseImageGenerationTool(body) { + return normalizedProtocolImageCount(body["n"]) + } + } + return 0 +} + +func normalizedProtocolImageCount(value any) int { + n := util.ToInt(value, 1) + if n < 1 { + return 1 + } + if n > 4 { + return 4 + } + return n +} + +func billableProtocolOutputCount(endpoint string, result map[string]any) int { + if len(result) == 0 { + return 0 + } + switch endpoint { + case "/v1/images/generations", "/v1/images/edits": + return billableImageDataCount(result["data"]) + case "/v1/chat/completions": + return countChatCompletionImages(result) + case "/v1/responses": + return countResponseOutputImages(result) + default: + return billableURLCount(collectURLs(result)) + } +} + +func billableProtocolStreamItemCount(endpoint string, item map[string]any) int { + if len(item) == 0 { + return 0 + } + switch endpoint { + case "/v1/images/generations", "/v1/images/edits": + if util.Clean(item["object"]) == "image.generation.result" { + return billableImageDataCount(item["data"]) + } + case "/v1/chat/completions": + for _, choice := range util.AsMapSlice(item["choices"]) { + delta := util.StringMap(choice["delta"]) + if len(delta) == 0 { + delta = util.StringMap(choice["message"]) + } + if count := countImagesInChatContent(delta["content"]); count > 0 { + return count + } + } + case "/v1/responses": + eventType := util.Clean(item["type"]) + switch eventType { + case "response.output_item.done", "response.output_item.added": + if count := countResponseOutputItemImages(util.StringMap(item["item"])); count > 0 { + return count + } + } + } + return 0 +} + +func billableImageDataCount(value any) int { + count := 0 + for _, item := range util.AsMapSlice(value) { + if util.Clean(item["url"]) != "" || util.Clean(item["b64_json"]) != "" { + count++ + } + } + return count +} + +func countChatCompletionImages(result map[string]any) int { + count := 0 + for _, choice := range util.AsMapSlice(result["choices"]) { + message := util.StringMap(choice["message"]) + count += countImagesInChatContent(message["content"]) + } + return count +} + +func countImagesInChatContent(content any) int { + switch value := content.(type) { + case string: + return strings.Count(value, "![") + case []any: + count := 0 + for _, raw := range value { + item := util.StringMap(raw) + if util.Clean(item["type"]) == "image_url" || util.Clean(item["image_url"]) != "" { + count++ + } + if util.Clean(item["type"]) == "text" { + count += strings.Count(util.Clean(item["text"]), "![") + } + } + return count + default: + return 0 + } +} + +func countResponseOutputImages(result map[string]any) int { + count := 0 + for _, item := range util.AsMapSlice(result["output"]) { + count += countResponseOutputItemImages(item) + } + return count +} + +func countResponseOutputItemImages(item map[string]any) int { + if util.Clean(item["type"]) == "image_generation_call" && util.Clean(item["result"]) != "" { + return 1 + } + return 0 +} + +func billableURLCount(urls []string) int { + return len(dedupe(urls)) +} + func dedupe(items []string) []string { seen := map[string]struct{}{} var out []string diff --git a/internal/httpapi/app_test.go b/internal/httpapi/app_test.go index 616f10a81..f3405e01d 100644 --- a/internal/httpapi/app_test.go +++ b/internal/httpapi/app_test.go @@ -8,12 +8,16 @@ import ( "image" "image/color" "image/png" + "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "reflect" + "sort" + "strconv" "strings" "sync" "testing" @@ -22,6 +26,7 @@ import ( "chatgpt2api/internal/backend" "chatgpt2api/internal/protocol" "chatgpt2api/internal/service" + "chatgpt2api/internal/storage" "chatgpt2api/internal/util" "chatgpt2api/internal/version" ) @@ -289,6 +294,9 @@ func TestPasswordAccountLoginAndRegistrationToggle(t *testing.T) { if res.Code != http.StatusForbidden { t.Fatalf("disabled registration status = %d body = %s", res.Code, res.Body.String()) } + if !strings.Contains(res.Body.String(), "已关闭注册通道") { + t.Fatalf("disabled registration body = %s", res.Body.String()) + } req = httptest.NewRequest(http.MethodPost, "/api/settings", strings.NewReader(`{"registration_enabled":true}`)) req.Header.Set("Authorization", "Bearer "+adminToken) @@ -473,6 +481,49 @@ func TestCreationTaskFailureWritesCallLog(t *testing.T) { } } +func TestLogsEndpointUsesDefaultLogView(t *testing.T) { + app := newTestApp(t) + defer app.Close() + if _, err := app.config.Update(map[string]any{"default_log_view": "business"}); err != nil { + t.Fatalf("Update(default_log_view) error = %v", err) + } + if err := app.logs.Add("新增账号", map[string]any{"module": "accounts", "operation_type": "新增"}); err != nil { + t.Fatalf("Add(business log) error = %v", err) + } + if err := app.logs.Add("GET /api/profile", map[string]any{"method": "GET", "path": "/api/profile", "module": "profile", "status": 200, "log_level": "info"}); err != nil { + t.Fatalf("Add(noisy audit log) error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/logs", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("logs status = %d body = %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("logs json: %v", err) + } + if summaries := logPayloadSummaries(logItems(payload)); !reflect.DeepEqual(summaries, []string{"新增账号"}) { + t.Fatalf("default logs summaries = %#v", summaries) + } + + req = httptest.NewRequest(http.MethodGet, "/api/logs?view=all", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("logs all status = %d body = %s", res.Code, res.Body.String()) + } + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("logs all json: %v", err) + } + if summaries := logPayloadSummaries(logItems(payload)); !reflect.DeepEqual(summaries, []string{"GET /api/profile", "新增账号"}) { + t.Fatalf("all logs summaries = %#v", summaries) + } +} + func TestCreationTaskResponseImageRouteIsNotAnAdminTaskResource(t *testing.T) { app := newTestApp(t) defer app.Close() @@ -524,6 +575,83 @@ func TestRunLoggedImageTaskLogsTextOutputAsFailure(t *testing.T) { } } +func TestRecordGeneratedImagesForPayloadStoresReusableRequestMetadata(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + rel := "2026/05/12/reusable.png" + imagePath := filepath.Join(app.config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeHTTPTestPNG(imagePath); err != nil { + t.Fatalf("writeHTTPTestPNG() error = %v", err) + } + + app.recordGeneratedImagesForPayload( + service.Identity{ID: "admin", Role: service.AuthRoleAdmin, Name: "Admin"}, + []string{rel}, + service.ImageVisibilityPublic, + map[string]any{ + "prompt": "复用这个提示词", + "model": "gpt-image-2", + "quality": "high", + "image_resolution": "2k", + "size": "2048x2048", + "output_format": "jpeg", + "output_compression": 42, + "background": "transparent", + "moderation": "low", + "style": "vivid", + "partial_images": 2, + "input_image_mask": "mask-id", + "images": []protocol.UploadedImage{ + {Filename: "source.png", ContentType: "image/png", Data: []byte("reference-bytes")}, + }, + "share_prompt_parameters": true, + "share_reference_images": true, + }, + ) + + list := app.images.ListImages("http://127.0.0.1:8000", "", "", service.ImageAccessScope{Public: true}) + items := list["items"].([]map[string]any) + if len(items) != 1 { + t.Fatalf("ListImages() = %#v", list) + } + item := items[0] + if item["prompt"] != "复用这个提示词" || + item["model"] != "gpt-image-2" || + item["quality"] != "high" || + item["resolution_preset"] != "2k" || + item["requested_size"] != "2048x2048" || + item["output_format"] != "jpeg" || + item["output_compression"] != 42 || + item["background"] != "transparent" || + item["moderation"] != "low" || + item["style"] != "vivid" || + item["partial_images"] != 2 || + item["input_image_mask"] != "mask-id" { + t.Fatalf("reusable metadata = %#v", item) + } + referenceURLs, ok := item["reference_image_urls"].([]string) + if !ok || len(referenceURLs) != 1 || !strings.Contains(referenceURLs[0], "/image-references/") { + t.Fatalf("reference_image_urls = %#v", item["reference_image_urls"]) + } + parsedReferenceURL, err := url.Parse(referenceURLs[0]) + if err != nil { + t.Fatalf("parse reference url: %v", err) + } + req := httptest.NewRequest(http.MethodGet, parsedReferenceURL.RequestURI(), nil) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || res.Body.String() != "reference-bytes" { + t.Fatalf("public reference status/body = %d %q", res.Code, res.Body.String()) + } + if got := res.Header().Get("Content-Type"); got != "image/png" { + t.Fatalf("reference Content-Type = %q, want image/png", got) + } +} + func TestDirectImageGenerationUsesCreationLimiter(t *testing.T) { t.Setenv("CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT", "2") app := newTestApp(t) @@ -691,129 +819,842 @@ func TestDirectImageGenerationDoesNotLimitAdminToken(t *testing.T) { } } -func TestEmptyCollectionEndpointsReturnArrays(t *testing.T) { - app := newTestApp(t) - defer app.Close() - +func TestProtocolImageBillingInsufficientErrors(t *testing.T) { for _, tc := range []struct { - name string - path string - keys []string + name string + billingType string + standardBalance string + subscriptionQuota string + wantCode string + wantMessage string }{ - {name: "accounts", path: "/api/accounts", keys: []string{"items"}}, - {name: "images", path: "/api/images", keys: []string{"items", "groups"}}, + { + name: "standard", + billingType: service.BillingTypeStandard, + standardBalance: "0", + subscriptionQuota: "100", + wantCode: "user_balance_insufficient", + wantMessage: "user balance insufficient", + }, + { + name: "subscription", + billingType: service.BillingTypeSubscription, + standardBalance: "100", + subscriptionQuota: "0", + wantCode: "user_quota_exceeded", + wantMessage: "user quota exceeded", + }, } { t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - req.Header.Set("Authorization", adminAuthHeader(t, app)) + app := newTestAppWithBillingDefaults(t, tc.billingType, tc.standardBalance, tc.subscriptionQuota, service.BillingPeriodMonthly) + defer app.Close() + + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":1,"response_format":"url"}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) res := httptest.NewRecorder() app.Handler().ServeHTTP(res, req) - if res.Code != http.StatusOK { - t.Fatalf("%s status = %d body = %s", tc.path, res.Code, res.Body.String()) + if res.Code != http.StatusTooManyRequests { + t.Fatalf("image generation status = %d body = %s", res.Code, res.Body.String()) } + var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { - t.Fatalf("%s json: %v", tc.path, err) + t.Fatalf("error json: %v", err) } - for _, key := range tc.keys { - items, ok := payload[key].([]any) - if !ok || items == nil || len(items) != 0 { - t.Fatalf("%s %q = %#v, want empty array", tc.path, key, payload[key]) - } + errorBody := util.StringMap(payload["error"]) + if errorBody["type"] != "insufficient_quota" || errorBody["code"] != tc.wantCode || errorBody["message"] != tc.wantMessage { + t.Fatalf("error body = %#v", payload) } }) } } -func TestRBACPermissionsGateManagementAPIs(t *testing.T) { - app := newTestApp(t) - defer app.Close() +func TestProtocolBillableUnitsBoundaryAndEquivalenceClasses(t *testing.T) { + tests := []struct { + name string + endpoint string + body map[string]any + want int + }{ + { + name: "image generation defaults to one", + endpoint: "/v1/images/generations", + body: map[string]any{}, + want: 1, + }, + { + name: "image generation zero clamps to one", + endpoint: "/v1/images/generations", + body: map[string]any{"n": 0}, + want: 1, + }, + { + name: "image generation negative clamps to one", + endpoint: "/v1/images/generations", + body: map[string]any{"n": -3}, + want: 1, + }, + { + name: "image generation upper bound", + endpoint: "/v1/images/generations", + body: map[string]any{"n": 4}, + want: 4, + }, + { + name: "image generation above upper bound clamps", + endpoint: "/v1/images/generations", + body: map[string]any{"n": 5}, + want: 4, + }, + { + name: "text chat is free even with n", + endpoint: "/v1/chat/completions", + body: map[string]any{ + "model": "gpt-5", + "n": 4, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + }, + want: 0, + }, + { + name: "image chat defaults to one", + endpoint: "/v1/chat/completions", + body: map[string]any{ + "model": "gpt-5", + "modalities": []any{"image"}, + "messages": []any{map[string]any{"role": "user", "content": "draw"}}, + }, + want: 1, + }, + { + name: "image chat above upper bound clamps", + endpoint: "/v1/chat/completions", + body: map[string]any{ + "model": "gpt-5", + "modalities": []any{"image"}, + "n": 7, + "messages": []any{map[string]any{"role": "user", "content": "draw"}}, + }, + want: 4, + }, + { + name: "text responses are free", + endpoint: "/v1/responses", + body: map[string]any{ + "model": "gpt-5", + "input": "hello", + }, + want: 0, + }, + { + name: "responses image tool defaults to one", + endpoint: "/v1/responses", + body: map[string]any{ + "model": "gpt-image-2", + "input": "draw", + "tools": []any{map[string]any{"type": "image_generation"}}, + }, + want: 1, + }, + { + name: "responses image tool choice uses n upper bound", + endpoint: "/v1/responses", + body: map[string]any{ + "model": "gpt-image-2", + "input": "draw", + "n": 4, + "tool_choice": map[string]any{"type": "image_generation"}, + }, + want: 4, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := protocolBillableUnits(tc.endpoint, tc.body); got != tc.want { + t.Fatalf("protocolBillableUnits(%q, %#v) = %d, want %d", tc.endpoint, tc.body, got, tc.want) + } + }) + } +} - user, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "operator", service.AuthOwner{}) +func TestProtocolImageBillingStandardBalanceBoundary(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "4", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) if err != nil { t.Fatalf("CreateAPIKey() error = %v", err) } + installHTTPTestImageStream(t, app) - req := httptest.NewRequest(http.MethodGet, "/api/accounts", nil) + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":4,"response_format":"url"}`)) req.Header.Set("Authorization", "Bearer "+rawKey) res := httptest.NewRecorder() app.Handler().ServeHTTP(res, req) - if res.Code != http.StatusForbidden { - t.Fatalf("default user accounts status = %d body = %s", res.Code, res.Body.String()) - } - - role, err := app.auth.CreateRole(map[string]any{ - "name": "accounts viewer", - "menu_paths": []string{"/accounts"}, - "api_permissions": []string{service.APIPermissionKey(http.MethodGet, "/api/accounts")}, - }) - if err != nil { - t.Fatalf("CreateRole() error = %v", err) + if res.Code != http.StatusOK { + t.Fatalf("image generation exact-balance status = %d body = %s", res.Code, res.Body.String()) } - userID := user["id"].(string) - updated := app.auth.UpdateUser(userID, map[string]any{"role_id": role["id"]}) - if updated == nil { - t.Fatal("UpdateUser() returned nil") + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 4 || util.ToInt(state["available"], -1) != 0 { + t.Fatalf("billing after exact-balance image generation = %#v", state) } - req = httptest.NewRequest(http.MethodGet, "/auth/session", nil) + req = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":1,"response_format":"url"}`)) req.Header.Set("Authorization", "Bearer "+rawKey) res = httptest.NewRecorder() app.Handler().ServeHTTP(res, req) - if res.Code != http.StatusOK { - t.Fatalf("login after permission update status = %d body = %s", res.Code, res.Body.String()) + if res.Code != http.StatusTooManyRequests { + t.Fatalf("image generation drained-balance status = %d body = %s", res.Code, res.Body.String()) } - var login map[string]any - if err := json.Unmarshal(res.Body.Bytes(), &login); err != nil { - t.Fatalf("login json: %v", err) +} + +func TestProtocolImageBillingRejectsBeforeUpstream(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "3", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) } - if paths := util.AsStringSlice(login["menu_paths"]); len(paths) != 1 || paths[0] != "/accounts" { - t.Fatalf("login menu_paths = %#v", login["menu_paths"]) + streamCalls := 0 + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + streamCalls++ + return httpTestImageOutputStream(request, index) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":4,"response_format":"url"}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusTooManyRequests { + t.Fatalf("image generation insufficient status = %d body = %s", res.Code, res.Body.String()) } - if login["role_id"] != role["id"] || login["role_name"] != "accounts viewer" { - t.Fatalf("login role fields = %#v", login) + if streamCalls != 0 { + t.Fatalf("insufficient request reached upstream stream %d times", streamCalls) + } + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 3 || util.ToInt(standard["lifetime_consumed"], -1) != 0 || util.ToInt(state["available"], -1) != 3 { + t.Fatalf("billing after rejected image generation = %#v", state) } +} - req = httptest.NewRequest(http.MethodGet, "/api/accounts", nil) - req.Header.Set("Authorization", "Bearer "+rawKey) - res = httptest.NewRecorder() +func TestProtocolImageBillingChargesBeforeDelivery(t *testing.T) { + t.Run("non-stream does not return generated image when delivery charge fails", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "1", "0", service.BillingPeriodMonthly) + defer app.Close() + user, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + userID := util.Clean(user["id"]) + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + if _, err := app.billing.ChargeUserID(userID, 1, service.BillingReference{ChargeKey: "external:protocol:non-stream-drain"}); err != nil { + t.Errorf("external ChargeUserID() error = %v", err) + } + return httpTestImageOutputStream(request, index) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":1,"response_format":"url"}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusTooManyRequests { + t.Fatalf("image generation delivery charge status = %d body = %s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), "https://example.test/1.png") || strings.Contains(res.Body.String(), "image-1") { + t.Fatalf("unpaid generated image leaked in response body: %s", res.Body.String()) + } + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 1 || util.ToInt(state["available"], -1) != 0 { + t.Fatalf("billing after failed delivery charge = %#v", state) + } + }) + + t.Run("stream stops before unpaid image event", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "1", "0", service.BillingPeriodMonthly) + defer app.Close() + user, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + userID := util.Clean(user["id"]) + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + if _, err := app.billing.ChargeUserID(userID, 1, service.BillingReference{ChargeKey: "external:protocol:stream-drain"}); err != nil { + t.Errorf("external ChargeUserID() error = %v", err) + } + return httpTestImageOutputStream(request, index) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":1,"response_format":"url","stream":true}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + body := res.Body.String() + if res.Code != http.StatusOK { + t.Fatalf("stream image generation status = %d body = %s", res.Code, body) + } + if strings.Contains(body, "image.generation.result") || strings.Contains(body, "https://example.test/1.png") || strings.Contains(body, "image-1") { + t.Fatalf("unpaid generated image leaked in stream body: %s", body) + } + if !strings.Contains(body, `"code":"user_balance_insufficient"`) || !strings.Contains(body, "data: [DONE]") { + t.Fatalf("stream body missing billing error or done marker: %s", body) + } + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 1 || util.ToInt(state["available"], -1) != 0 { + t.Fatalf("billing after failed stream delivery charge = %#v", state) + } + }) +} + +func TestProtocolBillingChatAndResponsesEquivalenceClasses(t *testing.T) { + t.Run("text chat does not require billing", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "0", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5","messages":[{"role":"user","content":"hello"}]}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code == http.StatusTooManyRequests { + t.Fatalf("text chat was rejected by billing: %s", res.Body.String()) + } + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 0 || util.ToInt(state["available"], -1) != 0 { + t.Fatalf("billing changed after text chat = %#v", state) + } + }) + + t.Run("image chat consumes actual outputs", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "2", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + if index > 1 { + return httpTestMessageOnlyImageOutputStream(request, index) + } + return httpTestImageOutputStream(request, index) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-image-2","messages":[{"role":"user","content":"draw"}],"n":2}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("image chat status = %d body = %s", res.Code, res.Body.String()) + } + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 1 || util.ToInt(standard["lifetime_consumed"], -1) != 1 || util.ToInt(state["available"], -1) != 1 { + t.Fatalf("billing after partial image chat = %#v", state) + } + }) + + t.Run("image chat insufficient rejects before upstream", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "0", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + streamCalls := 0 + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + streamCalls++ + return httpTestImageOutputStream(request, index) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5","modalities":["image"],"messages":[{"role":"user","content":"draw"}],"n":1}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusTooManyRequests { + t.Fatalf("image chat insufficient status = %d body = %s", res.Code, res.Body.String()) + } + if streamCalls != 0 { + t.Fatalf("insufficient image chat reached upstream stream %d times", streamCalls) + } + }) + + t.Run("text responses do not require billing", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "0", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5","input":"hello"}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code == http.StatusTooManyRequests { + t.Fatalf("text responses was rejected by billing: %s", res.Body.String()) + } + state := profileBillingState(t, app, rawKey) + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 0 || util.ToInt(state["available"], -1) != 0 { + t.Fatalf("billing changed after text responses = %#v", state) + } + }) + + t.Run("responses image tool insufficient rejects before upstream", func(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "0", "0", service.BillingPeriodMonthly) + defer app.Close() + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + streamCalls := 0 + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + streamCalls++ + return httpTestImageOutputStream(request, index) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-image-2","input":"draw","tools":[{"type":"image_generation"}]}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusTooManyRequests { + t.Fatalf("responses image insufficient status = %d body = %s", res.Code, res.Body.String()) + } + if streamCalls != 0 { + t.Fatalf("insufficient responses image reached upstream stream %d times", streamCalls) + } + }) +} + +func TestProtocolBillingAdminBypassAndUserAdjustmentPermission(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "0", "0", service.BillingPeriodMonthly) + defer app.Close() + installHTTPTestImageStream(t, app) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"prompt":"draw","model":"gpt-image-2","n":4,"response_format":"url"}`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() app.Handler().ServeHTTP(res, req) if res.Code != http.StatusOK { - t.Fatalf("granted user accounts status = %d body = %s", res.Code, res.Body.String()) + t.Fatalf("admin image generation status = %d body = %s", res.Code, res.Body.String()) } - app.accounts.AddAccounts([]string{"pool-token"}) - req = httptest.NewRequest(http.MethodGet, "/api/accounts", nil) + user, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + req = httptest.NewRequest(http.MethodPost, "/api/admin/users/"+url.PathEscape(util.Clean(user["id"]))+"/billing-adjustments", strings.NewReader(`{"type":"increase_balance","amount":1,"reason":"user attempt"}`)) req.Header.Set("Authorization", "Bearer "+rawKey) res = httptest.NewRecorder() app.Handler().ServeHTTP(res, req) - if res.Code != http.StatusOK { - t.Fatalf("granted user accounts with token status = %d body = %s", res.Code, res.Body.String()) + if res.Code != http.StatusForbidden { + t.Fatalf("user billing adjustment status = %d body = %s", res.Code, res.Body.String()) } - var accountsBody map[string]any - if err := json.Unmarshal(res.Body.Bytes(), &accountsBody); err != nil { - t.Fatalf("accounts json: %v", err) +} + +func TestProfileAndManagedUsersExposeBillingState(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeSubscription, "0", "12", service.BillingPeriodWeekly) + defer app.Close() + + user, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "billing-user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) } - accountItems := logItems(accountsBody) - if len(accountItems) != 1 { - t.Fatalf("accounts body = %#v", accountsBody) + userID, _ := user["id"].(string) + + req := httptest.NewRequest(http.MethodGet, "/api/profile", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("profile status = %d body = %s", res.Code, res.Body.String()) } - if _, ok := accountItems[0]["access_token"]; ok { - t.Fatalf("account list should not expose access_token without export permission: %#v", accountItems[0]) + var profile map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &profile); err != nil { + t.Fatalf("profile json: %v", err) } - accountID, _ := accountItems[0]["id"].(string) - if accountID == "" || accountItems[0]["token_preview"] == "" { - t.Fatalf("account list missing id/token preview: %#v", accountItems[0]) + billing := util.StringMap(profile["billing"]) + subscription := util.StringMap(billing["subscription"]) + if billing["type"] != service.BillingTypeSubscription || util.ToInt(billing["available"], 0) != 12 || subscription["quota_period"] != service.BillingPeriodWeekly { + t.Fatalf("profile billing = %#v", billing) } - req = httptest.NewRequest(http.MethodGet, "/api/accounts/tokens", nil) - req.Header.Set("Authorization", "Bearer "+rawKey) + req = httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) res = httptest.NewRecorder() app.Handler().ServeHTTP(res, req) - if res.Code != http.StatusForbidden { - t.Fatalf("ungranted account token export status = %d body = %s", res.Code, res.Body.String()) + if res.Code != http.StatusOK { + t.Fatalf("admin users status = %d body = %s", res.Code, res.Body.String()) } - + var users map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &users); err != nil { + t.Fatalf("admin users json: %v", err) + } + item := findHTTPItem(logItems(users), userID) + if item == nil { + t.Fatalf("managed user %q missing from %#v", userID, users) + } + billing = util.StringMap(item["billing"]) + if billing["type"] != service.BillingTypeSubscription || util.ToInt(billing["available"], 0) != 12 { + t.Fatalf("managed user billing = %#v", item["billing"]) + } +} + +func TestDefaultBillingSettingsOnlyInitializeNewUsers(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "0", "0", service.BillingPeriodMonthly) + defer app.Close() + + existing, existingKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "existing user", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey(existing) error = %v", err) + } + existingID := util.Clean(existing["id"]) + + req := httptest.NewRequest(http.MethodPost, "/api/settings", strings.NewReader(`{ + "default_billing_type": "subscription", + "default_standard_balance": 7, + "default_subscription_quota": 12, + "default_subscription_period": "weekly" + }`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("update default billing settings status = %d body = %s", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("admin users status = %d body = %s", res.Code, res.Body.String()) + } + var users map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &users); err != nil { + t.Fatalf("admin users json: %v", err) + } + item := findHTTPItem(logItems(users), existingID) + if item == nil { + t.Fatalf("existing user %q missing from %#v", existingID, users) + } + billing := util.StringMap(item["billing"]) + if billing["type"] != service.BillingTypeStandard || util.ToInt(billing["available"], -1) != 0 { + t.Fatalf("existing listed billing changed after settings update = %#v", billing) + } + + req = httptest.NewRequest(http.MethodGet, "/api/profile", nil) + req.Header.Set("Authorization", "Bearer "+existingKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("existing profile status = %d body = %s", res.Code, res.Body.String()) + } + var profile map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &profile); err != nil { + t.Fatalf("existing profile json: %v", err) + } + billing = util.StringMap(profile["billing"]) + if billing["type"] != service.BillingTypeStandard || util.ToInt(billing["available"], -1) != 0 { + t.Fatalf("existing profile billing changed after settings update = %#v", billing) + } + + req = httptest.NewRequest(http.MethodPost, "/api/admin/users", strings.NewReader(`{"username":"newuser","password":"Password123","name":"New User"}`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("create new user status = %d body = %s", res.Code, res.Body.String()) + } + var created map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &created); err != nil { + t.Fatalf("create user json: %v", err) + } + newUser := util.StringMap(created["item"]) + billing = util.StringMap(newUser["billing"]) + subscription := util.StringMap(billing["subscription"]) + if billing["type"] != service.BillingTypeSubscription || util.ToInt(billing["available"], -1) != 12 || subscription["quota_period"] != service.BillingPeriodWeekly { + t.Fatalf("new user billing did not use updated defaults = %#v", billing) + } +} + +func TestRegistrationInitializesDefaultBillingForNewUser(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeSubscription, "0", "9", service.BillingPeriodDaily) + defer app.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/settings", strings.NewReader(`{"registration_enabled":true}`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("enable registration status = %d body = %s", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/auth/register", strings.NewReader(`{"username":"alice","password":"Password123","name":"Alice"}`)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("register status = %d body = %s", res.Code, res.Body.String()) + } + var registered map[string]any + if err := json.Unmarshal(res.Body.Bytes(), ®istered); err != nil { + t.Fatalf("register json: %v", err) + } + billing := util.StringMap(registered["billing"]) + subscription := util.StringMap(billing["subscription"]) + if billing["type"] != service.BillingTypeSubscription || util.ToInt(billing["available"], -1) != 9 || subscription["quota_period"] != service.BillingPeriodDaily { + t.Fatalf("registered billing = %#v", billing) + } +} + +func TestAdminBulkBillingAdjustmentTargetsExplicitUsers(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "2", "0", service.BillingPeriodMonthly) + defer app.Close() + + alice, err := app.auth.CreatePasswordUser("bulk_alice", "Password123", "Bulk Alice", service.DefaultManagedRoleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(alice) error = %v", err) + } + bob, err := app.auth.CreatePasswordUser("bulk_bob", "Password123", "Bulk Bob", service.DefaultManagedRoleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(bob) error = %v", err) + } + aliceID := util.Clean(alice["id"]) + bobID := util.Clean(bob["id"]) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/users/billing-adjustments/bulk", strings.NewReader(`{ + "scope": "users", + "user_ids": [`+strconv.Quote(aliceID)+`, `+strconv.Quote(bobID)+`, `+strconv.Quote(aliceID)+`], + "billing": {"type":"increase_balance","amount":5,"reason":"batch topup"} + }`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("bulk users status = %d body = %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("bulk users json: %v", err) + } + summary := util.StringMap(payload["summary"]) + if util.ToInt(summary["total"], 0) != 2 || util.ToInt(summary["succeeded"], 0) != 2 || util.ToInt(summary["failed"], -1) != 0 { + t.Fatalf("bulk users summary = %#v", summary) + } + for _, userID := range []string{aliceID, bobID} { + billing := app.billing.Get(userID) + if util.ToInt(billing["available"], -1) != 7 { + t.Fatalf("%s billing = %#v, want available 7", userID, billing) + } + } + if adjustments := app.billing.ListAdjustments("", 10); len(adjustments) != 2 { + t.Fatalf("bulk adjustments len = %d, want 2: %#v", len(adjustments), adjustments) + } +} + +func TestAdminBulkBillingAdjustmentTargetsRoleAndReportsFailures(t *testing.T) { + app := newTestAppWithBillingDefaults(t, service.BillingTypeStandard, "2", "0", service.BillingPeriodMonthly) + defer app.Close() + + role, err := app.auth.CreateRole(map[string]any{ + "name": "bulk role", + "menu_paths": []string{}, + "api_permissions": []string{}, + }) + if err != nil { + t.Fatalf("CreateRole() error = %v", err) + } + roleID := util.Clean(role["id"]) + alice, err := app.auth.CreatePasswordUser("bulk_role_alice", "Password123", "Bulk Role Alice", roleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(alice) error = %v", err) + } + bob, err := app.auth.CreatePasswordUser("bulk_role_bob", "Password123", "Bulk Role Bob", roleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(bob) error = %v", err) + } + other, err := app.auth.CreatePasswordUser("bulk_role_other", "Password123", "Bulk Role Other", service.DefaultManagedRoleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(other) error = %v", err) + } + aliceID := util.Clean(alice["id"]) + bobID := util.Clean(bob["id"]) + otherID := util.Clean(other["id"]) + if _, err := app.billing.ApplyAdjustment(bobID, service.Identity{ID: "admin", Name: "Admin", Role: service.AuthRoleAdmin}, map[string]any{"type": "decrease_balance", "amount": 1}); err != nil { + t.Fatalf("pre-adjust bob error = %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/users/billing-adjustments/bulk", strings.NewReader(`{ + "scope": "role", + "role_id": `+strconv.Quote(roleID)+`, + "billing": {"type":"decrease_balance","amount":2,"reason":"batch debit"} + }`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("bulk role status = %d body = %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("bulk role json: %v", err) + } + summary := util.StringMap(payload["summary"]) + if util.ToInt(summary["total"], 0) != 2 || util.ToInt(summary["succeeded"], 0) != 1 || util.ToInt(summary["failed"], 0) != 1 { + t.Fatalf("bulk role summary = %#v", summary) + } + results := logItems(map[string]any{"items": payload["results"]}) + if len(results) != 2 { + t.Fatalf("bulk role results = %#v", payload["results"]) + } + if failed := findHTTPBulkBillingResult(results, bobID); failed == nil || util.Clean(failed["error"]) == "" { + t.Fatalf("bob failed result = %#v", failed) + } + if got := app.billing.Get(aliceID); util.ToInt(got["available"], -1) != 0 { + t.Fatalf("alice billing = %#v, want debited to 0", got) + } + if got := app.billing.Get(bobID); util.ToInt(got["available"], -1) != 1 { + t.Fatalf("bob billing = %#v, want unchanged at 1", got) + } + if got := app.billing.Get(otherID); util.ToInt(got["available"], -1) != 2 { + t.Fatalf("other billing = %#v, want unchanged at 2", got) + } +} + +func TestEmptyCollectionEndpointsReturnArrays(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + for _, tc := range []struct { + name string + path string + keys []string + }{ + {name: "accounts", path: "/api/accounts", keys: []string{"items"}}, + {name: "images", path: "/api/images", keys: []string{"items", "groups"}}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("%s status = %d body = %s", tc.path, res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("%s json: %v", tc.path, err) + } + for _, key := range tc.keys { + items, ok := payload[key].([]any) + if !ok || items == nil || len(items) != 0 { + t.Fatalf("%s %q = %#v, want empty array", tc.path, key, payload[key]) + } + } + }) + } +} + +func TestRBACPermissionsGateManagementAPIs(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + user, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "operator", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/accounts", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusForbidden { + t.Fatalf("default user accounts status = %d body = %s", res.Code, res.Body.String()) + } + + role, err := app.auth.CreateRole(map[string]any{ + "name": "accounts viewer", + "menu_paths": []string{"/accounts"}, + "api_permissions": []string{service.APIPermissionKey(http.MethodGet, "/api/accounts")}, + }) + if err != nil { + t.Fatalf("CreateRole() error = %v", err) + } + userID := user["id"].(string) + updated := app.auth.UpdateUser(userID, map[string]any{"role_id": role["id"]}) + if updated == nil { + t.Fatal("UpdateUser() returned nil") + } + + req = httptest.NewRequest(http.MethodGet, "/auth/session", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("login after permission update status = %d body = %s", res.Code, res.Body.String()) + } + var login map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &login); err != nil { + t.Fatalf("login json: %v", err) + } + if paths := util.AsStringSlice(login["menu_paths"]); len(paths) != 1 || paths[0] != "/accounts" { + t.Fatalf("login menu_paths = %#v", login["menu_paths"]) + } + if login["role_id"] != role["id"] || login["role_name"] != "accounts viewer" { + t.Fatalf("login role fields = %#v", login) + } + + req = httptest.NewRequest(http.MethodGet, "/api/accounts", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("granted user accounts status = %d body = %s", res.Code, res.Body.String()) + } + + app.accounts.AddAccounts([]string{"pool-token"}) + req = httptest.NewRequest(http.MethodGet, "/api/accounts", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("granted user accounts with token status = %d body = %s", res.Code, res.Body.String()) + } + var accountsBody map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &accountsBody); err != nil { + t.Fatalf("accounts json: %v", err) + } + accountItems := logItems(accountsBody) + if len(accountItems) != 1 { + t.Fatalf("accounts body = %#v", accountsBody) + } + if _, ok := accountItems[0]["access_token"]; ok { + t.Fatalf("account list should not expose access_token without export permission: %#v", accountItems[0]) + } + accountID, _ := accountItems[0]["id"].(string) + if accountID == "" || accountItems[0]["token_preview"] == "" { + t.Fatalf("account list missing id/token preview: %#v", accountItems[0]) + } + + req = httptest.NewRequest(http.MethodGet, "/api/accounts/tokens", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusForbidden { + t.Fatalf("ungranted account token export status = %d body = %s", res.Code, res.Body.String()) + } + req = httptest.NewRequest(http.MethodPost, "/api/accounts", strings.NewReader(`{"tokens":["x"]}`)) req.Header.Set("Authorization", "Bearer "+rawKey) res = httptest.NewRecorder() @@ -1253,11 +2094,29 @@ func TestManagedImageFilesRequireOwnerOrPublicAccess(t *testing.T) { if err := writeHTTPTestPNG(imagePath); err != nil { t.Fatalf("write image: %v", err) } - app.images.RecordGeneratedImages([]string{rel}, owner.ID, owner.Name, service.ImageVisibilityPrivate) - - req := httptest.NewRequest(http.MethodGet, "/images/2026/05/01", nil) - res := httptest.NewRecorder() - app.Handler().ServeHTTP(res, req) + app.images.RecordGeneratedImages([]string{rel}, owner.ID, owner.Name, service.ImageVisibilityPrivate, service.GeneratedImageMetadata{ + ReferenceImages: []service.GeneratedImageReference{ + {Filename: "private-source.png", ContentType: "image/png", Data: []byte("private-reference")}, + }, + }) + privateList := app.images.ListImages("http://127.0.0.1:8000", "", "", service.ImageAccessScope{All: true}) + privateItems := privateList["items"].([]map[string]any) + if len(privateItems) != 1 { + t.Fatalf("private image list = %#v", privateList) + } + privateReferenceURLs, ok := privateItems[0]["reference_image_urls"].([]string) + if !ok || len(privateReferenceURLs) != 1 { + t.Fatalf("private reference urls = %#v", privateItems[0]) + } + parsedPrivateReferenceURL, err := url.Parse(privateReferenceURLs[0]) + if err != nil { + t.Fatalf("parse private reference url: %v", err) + } + privateReferencePath := parsedPrivateReferenceURL.RequestURI() + + req := httptest.NewRequest(http.MethodGet, "/images/2026/05/01", nil) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) if res.Code != http.StatusNotFound { t.Fatalf("image directory listing status = %d body = %q, want 404", res.Code, res.Body.String()) } @@ -1269,6 +2128,29 @@ func TestManagedImageFilesRequireOwnerOrPublicAccess(t *testing.T) { t.Fatalf("anonymous private image status = %d body = %q, want 401", res.Code, res.Body.String()) } + req = httptest.NewRequest(http.MethodGet, privateReferencePath, nil) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusUnauthorized { + t.Fatalf("anonymous private reference status = %d body = %q, want 401", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, privateReferencePath, nil) + req.Header.Set("Authorization", "Bearer "+bobKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusNotFound { + t.Fatalf("other user private reference status = %d body = %q, want 404", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, privateReferencePath, nil) + req.Header.Set("Authorization", "Bearer "+aliceKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || res.Body.String() != "private-reference" { + t.Fatalf("owner private reference status/body = %d %q", res.Code, res.Body.String()) + } + req = httptest.NewRequest(http.MethodGet, "/images/"+rel, nil) req.Header.Set("Authorization", "Bearer "+bobKey) res = httptest.NewRecorder() @@ -1316,6 +2198,23 @@ func TestManagedImageFilesRequireOwnerOrPublicAccess(t *testing.T) { if res.Code != http.StatusOK { t.Fatalf("anonymous public image status = %d body = %q", res.Code, res.Body.String()) } + + req = httptest.NewRequest(http.MethodGet, privateReferencePath, nil) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusUnauthorized { + t.Fatalf("anonymous unshared public reference status = %d body = %q, want 401", res.Code, res.Body.String()) + } + + if _, err := app.images.UpdateImageVisibility(rel, service.ImageVisibilityPublic, service.ImageAccessScope{OwnerID: owner.ID}, service.ImageVisibilityUpdateOptions{SharePromptParams: true, ShareReferences: true}); err != nil { + t.Fatalf("publish reference metadata: %v", err) + } + req = httptest.NewRequest(http.MethodGet, privateReferencePath, nil) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK || res.Body.String() != "private-reference" { + t.Fatalf("anonymous shared public reference status/body = %d %q", res.Code, res.Body.String()) + } } func TestImageThumbnailsAreGeneratedOnDemand(t *testing.T) { @@ -2149,6 +3048,132 @@ func TestAdminUsersManageLinuxDoUsers(t *testing.T) { } } +func TestAdminUsersListPaginationAndFilters(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + enabledOne, err := app.auth.CreatePasswordUser("enabled_one", "Password123", "Enabled One", service.DefaultManagedRoleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(enabled_one) error = %v", err) + } + disabledOne, err := app.auth.CreatePasswordUser("disabled_one", "Password123", "Disabled One", service.DefaultManagedRoleID, false) + if err != nil { + t.Fatalf("CreatePasswordUser(disabled_one) error = %v", err) + } + enabledTwo, err := app.auth.CreatePasswordUser("enabled_two", "Password123", "Enabled Two", service.DefaultManagedRoleID, true) + if err != nil { + t.Fatalf("CreatePasswordUser(enabled_two) error = %v", err) + } + expectedDefaultIDs := []string{ + enabledOne["id"].(string), + disabledOne["id"].(string), + enabledTwo["id"].(string), + } + sort.Sort(sort.Reverse(sort.StringSlice(expectedDefaultIDs))) + + req := httptest.NewRequest(http.MethodGet, "/api/admin/users?page=1&page_size=3", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("default sorted users status = %d body = %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("default sorted users json: %v", err) + } + items := logItems(payload) + if len(items) != len(expectedDefaultIDs) || payload["sort_by"] != "id" || payload["sort_order"] != "desc" { + t.Fatalf("default sorted metadata/items = %#v", payload) + } + for index, item := range items { + if item["id"] != expectedDefaultIDs[index] { + t.Fatalf("default sorted ids = %#v, want %#v", items, expectedDefaultIDs) + } + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=2&page_size=2", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("paged users status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("paged users json: %v", err) + } + if payload["total"] != float64(3) || payload["page"] != float64(2) || payload["page_size"] != float64(2) || payload["total_pages"] != float64(2) { + t.Fatalf("paged metadata = %#v", payload) + } + if items := logItems(payload); len(items) != 1 { + t.Fatalf("paged items length = %d payload = %#v", len(items), payload) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=1&page_size=3&sort_by=username&sort_order=asc", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("username sorted users status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("username sorted users json: %v", err) + } + items = logItems(payload) + if payload["sort_by"] != "username" || payload["sort_order"] != "asc" || len(items) != 3 { + t.Fatalf("username sorted payload = %#v", payload) + } + for index, username := range []string{"disabled_one", "enabled_one", "enabled_two"} { + if items[index]["username"] != username { + t.Fatalf("username sorted items = %#v", items) + } + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=99&page_size=2", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("clamped users status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("clamped users json: %v", err) + } + if payload["page"] != float64(2) || payload["total_pages"] != float64(2) { + t.Fatalf("clamped metadata = %#v", payload) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=1&page_size=20&provider=local&status=disabled&search=disabled_one", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("filtered users status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("filtered users json: %v", err) + } + items = logItems(payload) + if payload["total"] != float64(1) || len(items) != 1 || items[0]["username"] != "disabled_one" { + t.Fatalf("filtered users payload = %#v", payload) + } + if _, ok := items[0]["usage_curve"].([]any); !ok { + t.Fatalf("filtered user missing usage stats: %#v", items[0]) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=0", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusBadRequest { + t.Fatalf("invalid page status = %d body = %s", res.Code, res.Body.String()) + } +} + func TestLinuxDoOAuthCallbackCreatesSession(t *testing.T) { oauthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -2182,6 +3207,9 @@ func TestLinuxDoOAuthCallbackCreatesSession(t *testing.T) { app := newTestApp(t) defer app.Close() + if _, err := app.config.Update(map[string]any{"registration_enabled": true}); err != nil { + t.Fatalf("enable registration: %v", err) + } req := httptest.NewRequest(http.MethodGet, "/auth/linuxdo/start?redirect=/settings", nil) res := httptest.NewRecorder() @@ -2256,6 +3284,127 @@ func TestLinuxDoOAuthCallbackCreatesSession(t *testing.T) { } } +func TestLinuxDoOAuthCallbackRejectsNewUserWhenRegistrationDisabled(t *testing.T) { + oauthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + util.WriteJSON(w, http.StatusOK, map[string]any{"access_token": "linuxdo-access", "token_type": "Bearer"}) + case "/user": + util.WriteJSON(w, http.StatusOK, map[string]any{"id": 456, "username": "blocked_linuxdo", "trust_level": 1}) + default: + http.NotFound(w, r) + } + })) + defer oauthServer.Close() + + t.Setenv("CHATGPT2API_LINUXDO_ENABLED", "true") + t.Setenv("CHATGPT2API_LINUXDO_CLIENT_ID", "client-id") + t.Setenv("CHATGPT2API_LINUXDO_CLIENT_SECRET", "client-secret") + t.Setenv("CHATGPT2API_LINUXDO_AUTHORIZE_URL", oauthServer.URL+"/authorize") + t.Setenv("CHATGPT2API_LINUXDO_TOKEN_URL", oauthServer.URL+"/token") + t.Setenv("CHATGPT2API_LINUXDO_USERINFO_URL", oauthServer.URL+"/user") + t.Setenv("CHATGPT2API_LINUXDO_REDIRECT_URL", "http://chatgpt2api.test/auth/linuxdo/oauth/callback") + t.Setenv("CHATGPT2API_LINUXDO_FRONTEND_REDIRECT_URL", "/auth/linuxdo/callback") + + app := newTestApp(t) + defer app.Close() + + req := httptest.NewRequest(http.MethodGet, "/auth/linuxdo/start?redirect=/settings", nil) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusFound { + t.Fatalf("start status = %d body = %s", res.Code, res.Body.String()) + } + authorizeURL, err := url.Parse(res.Header().Get("Location")) + if err != nil { + t.Fatalf("parse authorize location: %v", err) + } + state := authorizeURL.Query().Get("state") + if state == "" { + t.Fatalf("authorize location missing state: %s", authorizeURL.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/auth/linuxdo/oauth/callback?code=oauth-code&state="+url.QueryEscape(state), nil) + for _, cookie := range res.Result().Cookies() { + req.AddCookie(cookie) + } + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusFound { + t.Fatalf("callback status = %d body = %s", res.Code, res.Body.String()) + } + callbackURL, err := url.Parse(res.Header().Get("Location")) + if err != nil { + t.Fatalf("parse callback location: %v", err) + } + fragment, err := url.ParseQuery(callbackURL.Fragment) + if err != nil { + t.Fatalf("parse callback fragment: %v", err) + } + if fragment.Get("error") != "registration_disabled" || fragment.Get("error_message") != "已关闭注册通道" { + t.Fatalf("callback fragment = %#v", fragment) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("admin users status = %d body = %s", res.Code, res.Body.String()) + } + var users map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &users); err != nil { + t.Fatalf("admin users json: %v", err) + } + if linuxdoUser := findHTTPItem(logItems(users), "linuxdo:456"); linuxdoUser != nil { + t.Fatalf("disabled registration created linuxdo user: %#v", linuxdoUser) + } + + if _, _, err := app.auth.UpsertLinuxDoSession(service.AuthOwner{ + ID: "linuxdo:456", + Name: "blocked_linuxdo", + Provider: service.AuthProviderLinuxDo, + LinuxDoLevel: "1", + }); err != nil { + t.Fatalf("seed existing linuxdo user: %v", err) + } + + req = httptest.NewRequest(http.MethodGet, "/auth/linuxdo/start?redirect=/settings", nil) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusFound { + t.Fatalf("second start status = %d body = %s", res.Code, res.Body.String()) + } + authorizeURL, err = url.Parse(res.Header().Get("Location")) + if err != nil { + t.Fatalf("parse second authorize location: %v", err) + } + state = authorizeURL.Query().Get("state") + if state == "" { + t.Fatalf("second authorize location missing state: %s", authorizeURL.String()) + } + req = httptest.NewRequest(http.MethodGet, "/auth/linuxdo/oauth/callback?code=oauth-code&state="+url.QueryEscape(state), nil) + for _, cookie := range res.Result().Cookies() { + req.AddCookie(cookie) + } + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusFound { + t.Fatalf("second callback status = %d body = %s", res.Code, res.Body.String()) + } + callbackURL, err = url.Parse(res.Header().Get("Location")) + if err != nil { + t.Fatalf("parse second callback location: %v", err) + } + fragment, err = url.ParseQuery(callbackURL.Fragment) + if err != nil { + t.Fatalf("parse second callback fragment: %v", err) + } + if fragment.Get("error") != "" || fragment.Get("key") == "" || fragment.Get("subject_id") != "linuxdo:456" { + t.Fatalf("existing user callback fragment = %#v", fragment) + } +} + func TestCreationTaskPollingDisablesCaching(t *testing.T) { app := newTestApp(t) defer app.Close() @@ -2329,9 +3478,85 @@ func TestModelsCallLogIncludesUserKeyName(t *testing.T) { detail["status"] != float64(http.StatusOK) || detail["outcome"] != "success" || detail["key_name"] != "frontend" || + detail["auth_kind"] != service.AuthKindAPIKey || detail["key_role"] != "user" { t.Fatalf("models call log did not include user key identity: %#v", detail) } + if _, ok := detail["session_name"]; ok { + t.Fatalf("api key log should not include session_name: %#v", detail) + } +} + +func TestProtocolCallLogCapturesUnknownLengthRequestWithoutDuplicateAudit(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "frontend", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + app.engine.ImageTokenProvider = func(context.Context) (string, error) { + return "test-token", nil + } + app.engine.ImageClientFactory = func(string) *backend.Client { + return nil + } + app.engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + out := make(chan protocol.ImageOutput, 1) + errCh := make(chan error, 1) + out <- protocol.ImageOutput{ + Kind: "result", + Model: request.Model, + Index: index, + Total: total, + Created: 123, + Data: []map[string]any{{"url": "https://example.test/image.png"}}, + } + close(out) + errCh <- nil + close(errCh) + return out, errCh + } + + body := `{"prompt":"draw a cat","model":"gpt-image-2","n":1,"response_format":"url"}` + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations?trace=1", io.NopCloser(strings.NewReader(body))) + req.ContentLength = -1 + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("image generation status = %d body = %s", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/api/logs", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("logs status = %d body = %s", res.Code, res.Body.String()) + } + var logs map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &logs); err != nil { + t.Fatalf("logs json: %v", err) + } + items := logItems(logs) + callLog := findLogByDetails(items, map[string]any{"endpoint": "/v1/images/generations", "outcome": "success"}) + if callLog == nil { + t.Fatalf("expected image call log, got %#v", items) + } + detail, _ := callLog["detail"].(map[string]any) + requestArgs, _ := detail["request_args"].(map[string]any) + query, _ := requestArgs["query"].(map[string]any) + requestBody, _ := requestArgs["body"].(map[string]any) + if query["trace"] != "1" || requestBody["model"] != "gpt-image-2" || requestBody["prompt"] != "draw a cat" { + t.Fatalf("request args not captured completely: %#v", requestArgs) + } + if detail["request_truncated"] != nil { + t.Fatalf("small request should not be marked truncated: %#v", detail) + } + if auditLog := findHTTPAuditLogByPath(items, "/v1/images/generations"); auditLog != nil { + t.Fatalf("protocol request should not also create generic audit log: %#v", auditLog) + } } func TestAPIAuditLogCapturesRequestMetadata(t *testing.T) { @@ -2348,7 +3573,7 @@ func TestAPIAuditLogCapturesRequestMetadata(t *testing.T) { t.Fatalf("settings status = %d body = %s", res.Code, res.Body.String()) } - req = httptest.NewRequest(http.MethodGet, "/api/logs?username=admin&method=GET&status=200&summary=%2Fapi%2Fsettings", nil) + req = httptest.NewRequest(http.MethodGet, "/api/logs?username=admin&method=GET&status=200&summary=%2Fapi%2Fsettings&view=all", nil) req.Header.Set("Authorization", adminAuthHeader(t, app)) res = httptest.NewRecorder() app.Handler().ServeHTTP(res, req) @@ -2377,23 +3602,87 @@ func TestAPIAuditLogCapturesRequestMetadata(t *testing.T) { if detail["operation_type"] != "查询" || detail["subject_id"] != testAdminUsername || detail["user_agent"] != "chatgpt2api-test" { t.Fatalf("missing audit identity/request fields = %#v", detail) } + if detail["username"] != "管理员" || detail["session_name"] != "登录会话" || detail["auth_kind"] != service.AuthKindSession { + t.Fatalf("session audit detail should use username/session fields instead of token name: %#v", detail) + } + if _, ok := detail["key_name"]; ok { + t.Fatalf("session audit detail should not expose 登录会话 as key_name: %#v", detail) + } if _, ok := detail["duration_ms"].(float64); !ok { t.Fatalf("duration_ms not numeric in audit detail = %#v", detail) } } +func TestCreationTaskSubmitLogsRequestAndPollingAvoidsGenericAuditNoise(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + _, rawKey, err := app.auth.CreateAPIKey(service.AuthRoleUser, "frontend", service.AuthOwner{}) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/creation-tasks/image-generations", strings.NewReader(`{"client_task_id":"noise-test","prompt":"test image"}`)) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("submit creation task status = %d body = %s", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/api/creation-tasks?ids=noise-test", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("poll creation task status = %d body = %s", res.Code, res.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/api/logs?view=all", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("logs status = %d body = %s", res.Code, res.Body.String()) + } + var logs map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &logs); err != nil { + t.Fatalf("logs json: %v", err) + } + items := logItems(logs) + submitLog := findHTTPAuditLogByPath(items, "/api/creation-tasks/image-generations") + if submitLog == nil { + t.Fatalf("creation task submit should create a request log, got %#v", items) + } + detail, _ := submitLog["detail"].(map[string]any) + requestArgs, _ := detail["request_args"].(map[string]any) + if requestArgs["client_task_id"] != "noise-test" || requestArgs["prompt"] != "test image" { + t.Fatalf("creation task submit request args = %#v", requestArgs) + } + if auditLog := findHTTPAuditLogByPath(items, "/api/creation-tasks"); auditLog != nil { + t.Fatalf("creation task polling should not create generic audit log: %#v", auditLog) + } +} + func TestLogGovernanceEndpointCleansOldLogs(t *testing.T) { app := newTestApp(t) defer app.Close() - logDir := filepath.Join(app.config.DataDir, "logs") - if err := os.MkdirAll(logDir, 0o755); err != nil { - t.Fatalf("mkdir logs: %v", err) + backend, err := app.config.StorageBackend() + if err != nil { + t.Fatalf("StorageBackend() error = %v", err) } - logData := []byte(`{"time":"2000-01-01 00:00:00","type":"event","summary":"旧日志","detail":{"status":"success"}}` + "\n" + - `{"time":"` + time.Now().Format("2006-01-02 15:04:05") + `","type":"event","summary":"新日志","detail":{"status":200}}` + "\n") - if err := os.WriteFile(filepath.Join(logDir, "events.jsonl"), logData, 0o644); err != nil { - t.Fatalf("write log data: %v", err) + logStore, ok := backend.(storage.LogBackend) + if !ok { + t.Fatalf("storage backend %T does not implement LogBackend", backend) + } + for _, item := range []map[string]any{ + {"time": time.Now().AddDate(0, 0, -2).Format("2006-01-02 15:04:05"), "type": "event", "summary": "旧日志", "detail": map[string]any{"status": "success"}}, + {"time": time.Now().Format("2006-01-02 15:04:05"), "type": "event", "summary": "新日志", "detail": map[string]any{"status": 200}}, + } { + if err := logStore.AppendLog(item); err != nil { + t.Fatalf("AppendLog() error = %v", err) + } } req := httptest.NewRequest(http.MethodGet, "/api/logs/governance", nil) @@ -2424,11 +3713,125 @@ func TestLogGovernanceEndpointCleansOldLogs(t *testing.T) { t.Fatalf("cleanup json: %v", err) } cleanup, _ := payload["cleanup"].(map[string]any) - if cleanup["deleted"] != float64(1) || cleanup["remaining"] != float64(2) { - t.Fatalf("cleanup result = %#v, want deleted 1 remaining 2", cleanup) + if cleanup["deleted"] != float64(1) || cleanup["remaining"] != float64(1) { + t.Fatalf("cleanup result = %#v, want deleted 1 remaining 1", cleanup) + } +} + +func TestNewAppStartsLogRetentionCleaner(t *testing.T) { + root := t.TempDir() + t.Setenv("CHATGPT2API_ROOT", root) + t.Setenv("CHATGPT2API_ADMIN_USERNAME", testAdminUsername) + t.Setenv("CHATGPT2API_ADMIN_PASSWORD", testAdminPassword) + t.Setenv("STORAGE_BACKEND", "sqlite") + t.Setenv("DATABASE_URL", "") + t.Setenv("CHATGPT2API_LOG_RETENTION_DAYS", "1") + unsetTestEnv(t, "CHATGPT2API_REGISTRATION_ENABLED") + + dataDir := filepath.Join(root, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatalf("mkdir data dir: %v", err) + } + backend, err := storage.NewBackendFromEnv(dataDir) + if err != nil { + t.Fatalf("NewBackendFromEnv() error = %v", err) + } + logStore, ok := backend.(storage.LogBackend) + if !ok { + t.Fatalf("storage backend %T does not implement LogBackend", backend) + } + for _, item := range []map[string]any{ + {"time": "2000-01-01 00:00:00", "type": "event", "summary": "旧日志", "detail": map[string]any{"status": "success"}}, + {"time": time.Now().Format("2006-01-02 15:04:05"), "type": "event", "summary": "新日志", "detail": map[string]any{"status": 200}}, + } { + if err := logStore.AppendLog(item); err != nil { + t.Fatalf("AppendLog() error = %v", err) + } + } + if closer, ok := backend.(interface{ Close() error }); ok { + if err := closer.Close(); err != nil { + t.Fatalf("close seed backend: %v", err) + } + } + + app, err := NewApp() + if err != nil { + t.Fatalf("NewApp() error = %v", err) + } + defer app.Close() + + waitForHTTPTestCondition(t, func() bool { + items := app.logs.Search(service.LogQuery{Limit: 10}) + return len(items) == 1 && items[0]["summary"] == "新日志" + }) +} + +func TestImageStorageGovernanceEndpointCleansThumbnails(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + rel := "2026/04/29/sample.png" + imagePath := filepath.Join(app.config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { + t.Fatalf("mkdir image dir: %v", err) + } + if err := writeHTTPTestPNG(imagePath); err != nil { + t.Fatalf("write image: %v", err) + } + app.images.RecordGeneratedImages([]string{rel}, "admin", "Admin", service.ImageVisibilityPrivate) + app.images.EnsureThumbnails([]string{rel}) + thumbPath := filepath.Join(app.config.ImageThumbnailsDir(), filepath.FromSlash(rel)+".jpg") + if _, err := os.Stat(thumbPath); err != nil { + t.Fatalf("thumbnail was not created: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/images/storage-governance", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("storage governance status = %d body = %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("storage governance json: %v", err) + } + governance, _ := payload["governance"].(map[string]any) + if governance["images_count"] != float64(1) || governance["thumbnail_files"] != float64(1) { + t.Fatalf("storage governance = %#v", governance) + } + + req = httptest.NewRequest(http.MethodPost, "/api/images/storage-governance", strings.NewReader(`{"action":"thumbnails"}`)) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("thumbnail cleanup status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("thumbnail cleanup json: %v", err) + } + cleanup, _ := payload["cleanup"].(map[string]any) + if cleanup["deleted_thumbnails"] != float64(1) || cleanup["deleted_images"] != float64(0) { + t.Fatalf("thumbnail cleanup = %#v", cleanup) + } + if _, err := os.Stat(imagePath); err != nil { + t.Fatalf("image should remain after thumbnail cleanup: %v", err) + } + if _, err := os.Stat(thumbPath); !os.IsNotExist(err) { + t.Fatalf("thumbnail still exists, stat error = %v", err) } } +func logPayloadSummaries(items []map[string]any) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, util.Clean(item["summary"])) + } + return out +} + func logItems(payload map[string]any) []map[string]any { rawItems, _ := payload["items"].([]any) items := make([]map[string]any, 0, len(rawItems)) @@ -2458,6 +3861,15 @@ func findHTTPItem(items []map[string]any, id string) map[string]any { return nil } +func findHTTPBulkBillingResult(items []map[string]any, userID string) map[string]any { + for _, item := range items { + if item["user_id"] == userID { + return item + } + } + return nil +} + func findResponseCookie(res *http.Response, name string) *http.Cookie { for _, cookie := range res.Cookies() { if cookie.Name == name { @@ -2479,6 +3891,16 @@ func findLogByDetail(items []map[string]any, key, value string) map[string]any { return findLogByDetails(items, map[string]any{key: value}) } +func findHTTPAuditLogByPath(items []map[string]any, path string) map[string]any { + for _, item := range items { + detail, _ := item["detail"].(map[string]any) + if detail["path"] == path && detail["endpoint"] == nil { + return item + } + } + return nil +} + func findLogByDetails(items []map[string]any, values map[string]any) map[string]any { for _, item := range items { detail, _ := item["detail"].(map[string]any) @@ -2526,13 +3948,22 @@ func waitForHTTPTestCondition(t *testing.T, ok func() bool) { } func newTestApp(t *testing.T) *App { + return newTestAppWithBillingDefaults(t, "standard", "1000", "1000", "monthly") +} + +func newTestAppWithBillingDefaults(t *testing.T, billingType, standardBalance, subscriptionQuota, subscriptionPeriod string) *App { t.Helper() root := t.TempDir() t.Setenv("CHATGPT2API_ROOT", root) t.Setenv("CHATGPT2API_ADMIN_USERNAME", testAdminUsername) t.Setenv("CHATGPT2API_ADMIN_PASSWORD", testAdminPassword) + t.Setenv("CHATGPT2API_DEFAULT_BILLING_TYPE", billingType) + t.Setenv("CHATGPT2API_DEFAULT_STANDARD_BALANCE", standardBalance) + t.Setenv("CHATGPT2API_DEFAULT_SUBSCRIPTION_QUOTA", subscriptionQuota) + t.Setenv("CHATGPT2API_DEFAULT_SUBSCRIPTION_PERIOD", subscriptionPeriod) unsetTestEnv(t, "CHATGPT2API_REGISTRATION_ENABLED") - t.Setenv("STORAGE_BACKEND", "json") + unsetTestEnv(t, "CHATGPT2API_DEFAULT_LOG_VIEW") + t.Setenv("STORAGE_BACKEND", "sqlite") t.Setenv("DATABASE_URL", "") app, err := NewApp() if err != nil { @@ -2544,6 +3975,77 @@ func newTestApp(t *testing.T) *App { return app } +func installHTTPTestImageStream(t *testing.T, app *App) { + t.Helper() + installHTTPTestImageStreamFunc(t, app, func(ctx context.Context, client *backend.Client, request protocol.ConversationRequest, index, total int) (<-chan protocol.ImageOutput, <-chan error) { + return httpTestImageOutputStream(request, index) + }) +} + +func installHTTPTestImageStreamFunc(t *testing.T, app *App, fn func(context.Context, *backend.Client, protocol.ConversationRequest, int, int) (<-chan protocol.ImageOutput, <-chan error)) { + t.Helper() + app.engine.ImageTokenProvider = func(context.Context) (string, error) { + return "test-token", nil + } + app.engine.ImageClientFactory = func(string) *backend.Client { + return nil + } + app.engine.StreamImageOutputsFunc = fn +} + +func httpTestImageOutputStream(request protocol.ConversationRequest, index int) (<-chan protocol.ImageOutput, <-chan error) { + out := make(chan protocol.ImageOutput, 1) + errCh := make(chan error, 1) + out <- protocol.ImageOutput{ + Kind: "result", + Model: request.Model, + Index: index, + Total: request.N, + Created: int64(index), + Data: []map[string]any{{ + "url": fmt.Sprintf("https://example.test/%d.png", index), + "b64_json": fmt.Sprintf("image-%d", index), + }}, + } + close(out) + errCh <- nil + close(errCh) + return out, errCh +} + +func httpTestMessageOnlyImageOutputStream(request protocol.ConversationRequest, index int) (<-chan protocol.ImageOutput, <-chan error) { + out := make(chan protocol.ImageOutput, 1) + errCh := make(chan error, 1) + out <- protocol.ImageOutput{ + Kind: "message", + Model: request.Model, + Index: index, + Total: request.N, + Created: int64(index), + Text: "text only", + } + close(out) + errCh <- nil + close(errCh) + return out, errCh +} + +func profileBillingState(t *testing.T, app *App, rawKey string) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/profile", nil) + req.Header.Set("Authorization", "Bearer "+rawKey) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("profile status = %d body = %s", res.Code, res.Body.String()) + } + var profile map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &profile); err != nil { + t.Fatalf("profile json: %v", err) + } + return util.StringMap(profile["billing"]) +} + func unsetTestEnv(t *testing.T, key string) { t.Helper() original, existed := os.LookupEnv(key) diff --git a/internal/httpapi/audit.go b/internal/httpapi/audit.go index 17635fdf6..9165105d3 100644 --- a/internal/httpapi/audit.go +++ b/internal/httpapi/audit.go @@ -17,9 +17,31 @@ import ( "chatgpt2api/internal/util" ) -const maxAuditPayloadBytes = 8 * 1024 +const ( + maxAuditRequestPayloadBytes = 64 * 1024 + maxAuditResponsePayloadBytes = 8 * 1024 +) type requestIdentityContextKey struct{} +type auditRequestContextKey struct{} +type businessLogContextKey struct{} + +type auditRequestCapture struct { + args any + truncated bool +} + +type auditBodyReadCloser struct { + io.Reader + closer io.Closer +} + +func (r *auditBodyReadCloser) Close() error { + if r == nil || r.closer == nil { + return nil + } + return r.closer.Close() +} type auditResponseWriter struct { http.ResponseWriter @@ -39,8 +61,8 @@ func (w *auditResponseWriter) Write(data []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } - if w.body.Len() < maxAuditPayloadBytes { - remaining := maxAuditPayloadBytes - w.body.Len() + if w.body.Len() < maxAuditResponsePayloadBytes { + remaining := maxAuditResponsePayloadBytes - w.body.Len() if len(data) > remaining { _, _ = w.body.Write(data[:remaining]) } else { @@ -72,7 +94,8 @@ func (a *App) serveObservedHTTP(w http.ResponseWriter, r *http.Request, routes [ return } - requestArgs := captureAuditRequestArgs(r) + requestCapture := captureAuditRequest(r) + *r = *r.WithContext(withAuditRequestCapture(r.Context(), requestCapture)) recorder := &auditResponseWriter{ResponseWriter: w} start := time.Now() a.serveHTTP(recorder, r, routes) @@ -80,7 +103,9 @@ func (a *App) serveObservedHTTP(w http.ResponseWriter, r *http.Request, routes [ status := recorder.statusCode() a.logHTTPRequest(r, status, duration) - a.writeAuditLog(r, recorder, status, duration, requestArgs) + if shouldWriteAuditLog(r, status) { + a.writeAuditLog(r, recorder, status, duration, requestCapture) + } } func (a *App) logHTTPRequest(r *http.Request, status int, duration time.Duration) { @@ -100,11 +125,11 @@ func (a *App) logHTTPRequest(r *http.Request, status int, duration time.Duration case status >= http.StatusBadRequest: a.logger.Warning("http request", attrs...) default: - a.logger.Info("http request", attrs...) + a.logger.Debug("http request", attrs...) } } -func (a *App) writeAuditLog(r *http.Request, recorder *auditResponseWriter, status int, duration time.Duration, requestArgs any) { +func (a *App) writeAuditLog(r *http.Request, recorder *auditResponseWriter, status int, duration time.Duration, requestCapture auditRequestCapture) { if a.logs == nil { return } @@ -119,9 +144,7 @@ func (a *App) writeAuditLog(r *http.Request, recorder *auditResponseWriter, stat "operation_type": operationTypeForMethod(r.Method), "log_level": logLevelForStatus(status), } - if requestArgs != nil { - detail["request_args"] = requestArgs - } + addAuditRequestDetail(detail, requestCapture) if responseBody := normalizeAuditPayload(recorder.body.Bytes()); responseBody != nil { detail["response_body"] = responseBody } @@ -148,27 +171,110 @@ func requestIdentity(ctx context.Context) (service.Identity, bool) { return identity, ok } -func captureAuditRequestArgs(r *http.Request) any { +func withAuditRequestCapture(ctx context.Context, capture auditRequestCapture) context.Context { + return context.WithValue(ctx, auditRequestContextKey{}, capture) +} + +func requestAuditCapture(ctx context.Context) auditRequestCapture { + capture, _ := ctx.Value(auditRequestContextKey{}).(auditRequestCapture) + return capture +} + +func markRequestBusinessLogged(r *http.Request) { if r == nil { - return nil + return + } + *r = *r.WithContext(context.WithValue(r.Context(), businessLogContextKey{}, true)) +} + +func requestBusinessLogged(ctx context.Context) bool { + value, _ := ctx.Value(businessLogContextKey{}).(bool) + return value +} + +func addAuditRequestDetail(detail map[string]any, capture auditRequestCapture) { + if detail == nil { + return + } + if capture.args != nil { + detail["request_args"] = capture.args + } + if capture.truncated { + detail["request_truncated"] = true } +} + +func shouldWriteAuditLog(r *http.Request, status int) bool { + if r == nil { + return true + } + if requestBusinessLogged(r.Context()) { + return false + } + if status >= http.StatusBadRequest { + return true + } + return !isNoisySuccessfulAuditRequest(r) +} + +func isNoisySuccessfulAuditRequest(r *http.Request) bool { + if r == nil || r.URL == nil { + return false + } + path := r.URL.Path + switch r.Method { + case http.MethodGet, http.MethodHead: + switch { + case path == "/api/logs", + path == "/api/logs/governance", + path == "/api/images/storage-governance", + path == "/api/creation-tasks", + path == "/api/app-meta", + path == "/api/admin/permissions", + path == "/auth/session": + return true + } + } + return false +} + +func captureAuditRequest(r *http.Request) auditRequestCapture { + if r == nil { + return auditRequestCapture{} + } + query := captureAuditQuery(r) if strings.Contains(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") { - return "[multipart/form-data]" + return auditRequestCapture{args: combineAuditArgs(query, "[multipart/form-data]")} } if r.Method != http.MethodGet && r.Body != nil { - if r.ContentLength < 0 { - return "[body omitted: unknown size]" + body, truncated, ok := captureAuditBody(r) + if ok { + if bodyPayload := normalizeAuditPayload(body); bodyPayload != nil { + return auditRequestCapture{args: combineAuditArgs(query, bodyPayload), truncated: truncated} + } } - if r.ContentLength > maxAuditPayloadBytes { - return "[body omitted: too large]" - } - body, err := io.ReadAll(r.Body) - if err != nil { - r.Body = io.NopCloser(bytes.NewReader(nil)) - return nil - } - r.Body = io.NopCloser(bytes.NewBuffer(body)) - return normalizeAuditPayload(body) + } + return auditRequestCapture{args: query} +} + +func captureAuditBody(r *http.Request) ([]byte, bool, bool) { + if r == nil || r.Body == nil { + return nil, false, true + } + captured, err := io.ReadAll(io.LimitReader(r.Body, int64(maxAuditRequestPayloadBytes)+1)) + r.Body = &auditBodyReadCloser{Reader: io.MultiReader(bytes.NewReader(captured), r.Body), closer: r.Body} + if err != nil { + return nil, false, false + } + if len(captured) > maxAuditRequestPayloadBytes { + return captured[:maxAuditRequestPayloadBytes], true, true + } + return captured, false, true +} + +func captureAuditQuery(r *http.Request) any { + if r == nil || r.URL == nil { + return nil } if strings.TrimSpace(r.URL.RawQuery) == "" { return nil @@ -188,13 +294,23 @@ func captureAuditRequestArgs(r *http.Request) any { return service.SanitizeLogValue(payload) } +func combineAuditArgs(query, body any) any { + if query == nil { + return body + } + if body == nil { + return query + } + return map[string]any{"query": query, "body": body} +} + func normalizeAuditPayload(raw []byte) any { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 { return nil } - if len(trimmed) > maxAuditPayloadBytes { - trimmed = append([]byte(nil), trimmed[:maxAuditPayloadBytes]...) + if len(trimmed) > maxAuditRequestPayloadBytes { + trimmed = append([]byte(nil), trimmed[:maxAuditRequestPayloadBytes]...) } if json.Valid(trimmed) { var decoded any @@ -279,6 +395,7 @@ func parseLogQuery(r *http.Request) (service.LogQuery, error) { EndDate: strings.TrimSpace(values.Get("end_date")), StartTime: strings.TrimSpace(values.Get("start_time")), EndTime: strings.TrimSpace(values.Get("end_time")), + View: strings.TrimSpace(values.Get("view")), Limit: limit, }, nil } diff --git a/internal/httpapi/linuxdo.go b/internal/httpapi/linuxdo.go index 495b1a78d..e294dbbd9 100644 --- a/internal/httpapi/linuxdo.go +++ b/internal/httpapi/linuxdo.go @@ -163,13 +163,17 @@ func (a *App) handleLinuxDoOAuthCallback(w http.ResponseWriter, r *http.Request) } ownerID := linuxDoOwnerID(userInfo.Subject) - sessionItem, rawSessionKey, err := a.auth.UpsertLinuxDoSession(service.AuthOwner{ + sessionItem, rawSessionKey, err := a.auth.UpsertLinuxDoSessionIfAllowed(service.AuthOwner{ ID: ownerID, Name: userInfo.Username, Provider: service.AuthProviderLinuxDo, LinuxDoLevel: userInfo.Level, - }) + }, a.config.RegistrationEnabled()) if err != nil { + if errors.Is(err, service.ErrAuthUserCreationDisabled) { + redirectLinuxDoOAuthError(w, r, frontendCallback, "registration_disabled", "已关闭注册通道", "") + return + } redirectLinuxDoOAuthError(w, r, frontendCallback, "login_failed", "failed to create local session", "") return } diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 8d5118300..e71292d11 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -75,6 +75,7 @@ func (a *App) routes() []appRoute { exact(http.MethodGet, "/api/admin/permissions", a.handlePermissionCatalog), exact("", "/api/images/visibility", a.handleImageVisibility), exact("", "/api/images", a.handleImages), + exact("", "/api/images/storage-governance", a.handleImageStorageGovernance), exact("", "/api/logs/governance", a.handleLogGovernance), exact(http.MethodGet, "/api/logs", a.handleLogs), exact("", "/api/proxy", a.handleProxy), @@ -82,6 +83,7 @@ func (a *App) routes() []appRoute { exact(http.MethodGet, "/api/storage/info", a.handleStorageInfo), prefix("/images/", a.handleImageFile), + prefix("/image-references/", a.handleImageReferenceFile), prefix("/image-thumbnails/", a.handleImageThumbnail), prefix("/login-page-images/", http.StripPrefix("/login-page-images/", http.FileServer(http.Dir(a.config.LoginPageImagesDir()))).ServeHTTP), } diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index f683a098b..4c12fb736 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -4,9 +4,12 @@ import ( "errors" "fmt" "net/http" + "sort" + "strconv" "strings" "time" + "chatgpt2api/internal/protocol" "chatgpt2api/internal/service" "chatgpt2api/internal/util" ) @@ -366,14 +369,20 @@ func (a *App) handleAdminRoles(w http.ResponseWriter, r *http.Request) { } func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { - if _, ok := a.requireIdentity(w, r, ""); !ok { + operator, ok := a.requireIdentity(w, r, "") + if !ok { return } base := "/api/admin/users" if r.URL.Path == base { switch r.Method { case http.MethodGet: - util.WriteJSON(w, http.StatusOK, map[string]any{"items": a.managedUsers()}) + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + util.WriteJSON(w, http.StatusOK, response) case http.MethodPost: body, err := readJSONMap(r) if err != nil { @@ -395,11 +404,16 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { util.WriteError(w, http.StatusBadRequest, err.Error()) return } - items := a.managedUsers() - if current := findManagedUser(items, util.Clean(item["id"])); current != nil { + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if current := a.managedUser(util.Clean(item["id"])); current != nil { item = current } - util.WriteJSON(w, http.StatusOK, map[string]any{"item": item, "items": items}) + response["item"] = item + util.WriteJSON(w, http.StatusOK, response) default: w.WriteHeader(http.StatusMethodNotAllowed) } @@ -411,6 +425,40 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + if len(parts) == 5 && parts[3] == "billing-adjustments" && parts[4] == "bulk" { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + body, err := readJSONMap(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, "invalid json body") + return + } + targets, err := a.bulkBillingTargetUserIDs(body) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + billingBody := util.StringMap(body["billing"]) + if len(billingBody) == 0 { + billingBody = body + } + results, err := a.billing.ApplyBulkAdjustment(targets, operator, billingBody) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + response["results"] = publicBulkBillingAdjustmentResults(results) + response["summary"] = bulkBillingAdjustmentSummary(results) + util.WriteJSON(w, http.StatusOK, response) + return + } userID := parts[3] if len(parts) == 5 && parts[4] == "key" { if r.Method != http.MethodGet { @@ -458,11 +506,57 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { util.WriteError(w, http.StatusNotFound, "user not found") return } - items := a.managedUsers() - if current := findManagedUser(items, userID); current != nil { + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if current := a.managedUser(userID); current != nil { item = current } - util.WriteJSON(w, http.StatusOK, map[string]any{"item": item, "api_key": apiKey, "key": raw, "items": items}) + response["item"] = item + response["api_key"] = apiKey + response["key"] = raw + util.WriteJSON(w, http.StatusOK, response) + return + } + if len(parts) == 5 && parts[4] == "billing-adjustments" { + switch r.Method { + case http.MethodGet: + if findManagedUser(a.auth.ListUsers(), userID) == nil { + util.WriteError(w, http.StatusNotFound, "user not found") + return + } + util.WriteJSON(w, http.StatusOK, map[string]any{"items": a.billing.ListAdjustments(userID, util.ToInt(r.URL.Query().Get("limit"), 20))}) + case http.MethodPost: + body, err := readJSONMap(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, "invalid json body") + return + } + if findManagedUser(a.auth.ListUsers(), userID) == nil { + util.WriteError(w, http.StatusNotFound, "user not found") + return + } + result, err := a.billing.ApplyAdjustment(userID, operator, body) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if current := a.managedUser(userID); current != nil { + response["item"] = current + } + response["billing"] = result["billing"] + response["adjustment"] = result["adjustment"] + util.WriteJSON(w, http.StatusOK, response) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } return } if len(parts) != 4 { @@ -470,6 +564,14 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { return } switch r.Method { + case http.MethodGet: + item := a.managedUser(userID) + if item == nil { + util.WriteError(w, http.StatusNotFound, "user not found") + return + } + item["billing_adjustments"] = a.billing.ListAdjustments(userID, 10) + util.WriteJSON(w, http.StatusOK, map[string]any{"item": item}) case http.MethodPost: body, _ := readJSONMap(r) updates := map[string]any{} @@ -486,34 +588,126 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { } updates["role_id"] = value } - if len(updates) == 0 { + billingBody := util.StringMap(body["billing"]) + if len(updates) == 0 && len(billingBody) == 0 { util.WriteError(w, http.StatusBadRequest, "no updates provided") return } - item := a.auth.UpdateUser(userID, updates) - if item == nil { + if len(updates) > 0 { + if item := a.auth.UpdateUser(userID, updates); item == nil { + util.WriteError(w, http.StatusNotFound, "user not found") + return + } + } else if findManagedUser(a.auth.ListUsers(), userID) == nil { util.WriteError(w, http.StatusNotFound, "user not found") return } - items := a.managedUsers() - if current := findManagedUser(items, userID); current != nil { - item = current + if len(billingBody) > 0 { + if _, err := a.billing.ApplyAdjustment(userID, operator, billingBody); err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + } + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return } - util.WriteJSON(w, http.StatusOK, map[string]any{"item": item, "items": items}) + item := a.managedUser(userID) + response["item"] = item + util.WriteJSON(w, http.StatusOK, response) case http.MethodDelete: if !a.auth.DeleteUser(userID) { util.WriteError(w, http.StatusNotFound, "user not found") return } - util.WriteJSON(w, http.StatusOK, map[string]any{"items": a.managedUsers()}) + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + util.WriteJSON(w, http.StatusOK, response) default: w.WriteHeader(http.StatusMethodNotAllowed) } } -func (a *App) managedUsers() []map[string]any { - items := a.auth.ListUsers() - stats := a.logs.UserUsageStats(14) +type managedUsersQuery struct { + Page int + PageSize int + Search string + Provider string + Status string + SortBy string + SortOrder string + Total int + TotalPages int +} + +func (a *App) managedUsersResponse(r *http.Request) (map[string]any, error) { + query, err := parseManagedUsersQuery(r) + if err != nil { + return nil, err + } + items := filterManagedUsers(a.auth.ListUsers(), query) + a.prepareManagedUsersSortValues(items, query.SortBy) + sortManagedUsers(items, query) + query.Total = len(items) + query.TotalPages = managedUsersTotalPages(query.Total, query.PageSize) + if query.Page > query.TotalPages { + query.Page = query.TotalPages + } + start := (query.Page - 1) * query.PageSize + if start > query.Total { + start = query.Total + } + end := start + query.PageSize + if end > query.Total { + end = query.Total + } + pageItems := items[start:end] + a.attachManagedUserUsage(pageItems) + return map[string]any{ + "items": pageItems, + "total": query.Total, + "page": query.Page, + "page_size": query.PageSize, + "sort_by": query.SortBy, + "sort_order": query.SortOrder, + "total_pages": query.TotalPages, + }, nil +} + +func (a *App) managedUser(id string) map[string]any { + item := findManagedUser(a.auth.ListUsers(), id) + if item == nil { + return nil + } + a.attachManagedUserUsage([]map[string]any{item}) + return item +} + +func (a *App) attachManagedUserUsage(items []map[string]any) { + userIDs := managedUserIDs(items) + if len(userIDs) == 0 { + return + } + a.attachManagedUserUsageStats(items, userIDs) + a.attachManagedUserBillingStates(items, userIDs) +} + +func managedUserIDs(items []map[string]any) []string { + userIDs := make([]string, 0, len(items)) + for _, item := range items { + if userID := util.Clean(item["id"]); userID != "" { + userIDs = append(userIDs, userID) + } + } + return userIDs +} + +func (a *App) attachManagedUserUsageStats(items []map[string]any, userIDs []string) { + stats := a.logs.UserUsageStatsForUsers(14, userIDs) for _, item := range items { userID := util.Clean(item["id"]) usage := stats[userID] @@ -524,7 +718,345 @@ func (a *App) managedUsers() []map[string]any { item[key] = value } } - return items +} + +func (a *App) attachManagedUserBillingStates(items []map[string]any, userIDs []string) { + billingStates := a.billing.GetMany(userIDs) + for _, item := range items { + userID := util.Clean(item["id"]) + item["billing"] = billingStates[userID] + } +} + +func (a *App) prepareManagedUsersSortValues(items []map[string]any, sortBy string) { + if len(items) == 0 { + return + } + switch sortBy { + case "call_count", "quota_used", "failure_count": + a.attachManagedUserUsageStats(items, managedUserIDs(items)) + case "billing_available": + a.attachManagedUserBillingStates(items, managedUserIDs(items)) + } +} + +func (a *App) bulkBillingTargetUserIDs(body map[string]any) ([]string, error) { + scope := strings.ToLower(strings.TrimSpace(util.Clean(body["scope"]))) + if scope == "" { + scope = "users" + } + users := a.auth.ListUsers() + switch scope { + case "users": + rawIDs := util.AsStringSlice(body["user_ids"]) + if len(rawIDs) == 0 { + rawIDs = util.AsStringSlice(body["ids"]) + } + return existingManagedUserIDs(users, rawIDs) + case "role": + roleID := util.Clean(body["role_id"]) + if roleID == "" { + return nil, fmt.Errorf("role id is required") + } + if !a.auth.RoleExists(roleID) { + return nil, fmt.Errorf("role not found") + } + return managedUserIDsByRole(users, roleID) + default: + return nil, fmt.Errorf("unsupported billing target scope: %s", scope) + } +} + +func existingManagedUserIDs(items []map[string]any, requested []string) ([]string, error) { + available := map[string]struct{}{} + for _, item := range items { + if id := util.Clean(item["id"]); id != "" { + available[id] = struct{}{} + } + } + seen := map[string]struct{}{} + out := make([]string, 0, len(requested)) + for _, id := range requested { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + if _, ok := available[id]; !ok { + return nil, fmt.Errorf("user not found: %s", id) + } + seen[id] = struct{}{} + out = append(out, id) + } + if len(out) == 0 { + return nil, fmt.Errorf("user ids are required") + } + return out, nil +} + +func managedUserIDsByRole(items []map[string]any, roleID string) ([]string, error) { + out := make([]string, 0, len(items)) + for _, item := range items { + if util.Clean(item["role_id"]) != roleID { + continue + } + if id := util.Clean(item["id"]); id != "" { + out = append(out, id) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("role has no users") + } + return out, nil +} + +func publicBulkBillingAdjustmentResults(results []service.BillingBulkAdjustmentResult) []map[string]any { + out := make([]map[string]any, 0, len(results)) + for _, result := range results { + item := map[string]any{ + "user_id": result.UserID, + "billing": result.Billing, + } + if result.Adjustment != nil { + item["adjustment"] = result.Adjustment + } + if result.Error != "" { + item["error"] = result.Error + } + out = append(out, item) + } + return out +} + +func bulkBillingAdjustmentSummary(results []service.BillingBulkAdjustmentResult) map[string]any { + succeeded := 0 + failed := 0 + for _, result := range results { + if result.Error != "" { + failed++ + continue + } + succeeded++ + } + return map[string]any{ + "total": len(results), + "succeeded": succeeded, + "failed": failed, + } +} + +func parseManagedUsersQuery(r *http.Request) (managedUsersQuery, error) { + values := r.URL.Query() + page, err := parseManagedUsersPage(values.Get("page")) + if err != nil { + return managedUsersQuery{}, err + } + pageSize, err := parseManagedUsersPageSize(values.Get("page_size")) + if err != nil { + return managedUsersQuery{}, err + } + sortBy, err := parseManagedUsersSortBy(values.Get("sort_by")) + if err != nil { + return managedUsersQuery{}, err + } + sortOrder, err := parseManagedUsersSortOrder(values.Get("sort_order")) + if err != nil { + return managedUsersQuery{}, err + } + return managedUsersQuery{ + Page: page, + PageSize: pageSize, + Search: strings.TrimSpace(values.Get("search")), + Provider: strings.TrimSpace(values.Get("provider")), + Status: strings.TrimSpace(values.Get("status")), + SortBy: sortBy, + SortOrder: sortOrder, + }, nil +} + +func parseManagedUsersPage(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 1, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, fmt.Errorf("page 参数无效") + } + return value, nil +} + +func parseManagedUsersPageSize(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 20, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, fmt.Errorf("page_size 参数无效") + } + return normalizedManagedUsersPageSize(value), nil +} + +func normalizedManagedUsersPageSize(value int) int { + if value <= 0 { + return 20 + } + if value > 100 { + return 100 + } + return value +} + +func managedUsersTotalPages(total, pageSize int) int { + if pageSize <= 0 { + pageSize = 20 + } + if total <= 0 { + return 1 + } + return (total + pageSize - 1) / pageSize +} + +func parseManagedUsersSortBy(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "id", nil + } + switch value { + case "id", "name", "username", "provider", "enabled", "role_id", "role_name", "billing_available", "call_count", "quota_used", "failure_count", "created_at", "last_used_at", "updated_at": + return value, nil + default: + return "", fmt.Errorf("sort_by 参数无效") + } +} + +func parseManagedUsersSortOrder(raw string) (string, error) { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return "desc", nil + } + switch value { + case "asc", "desc": + return value, nil + default: + return "", fmt.Errorf("sort_order 参数无效") + } +} + +func filterManagedUsers(items []map[string]any, query managedUsersQuery) []map[string]any { + out := make([]map[string]any, 0, len(items)) + search := strings.ToLower(strings.TrimSpace(query.Search)) + provider := strings.TrimSpace(query.Provider) + status := strings.TrimSpace(query.Status) + for _, item := range items { + if provider != "" && provider != "all" && util.Clean(item["provider"]) != provider { + continue + } + if status == "enabled" && !util.ToBool(item["enabled"]) { + continue + } + if status == "disabled" && util.ToBool(item["enabled"]) { + continue + } + if search != "" && !strings.Contains(managedUserSearchText(item), search) { + continue + } + out = append(out, item) + } + return out +} + +func sortManagedUsers(items []map[string]any, query managedUsersQuery) { + desc := query.SortOrder == "desc" + sort.SliceStable(items, func(i, j int) bool { + cmp := compareManagedUsers(items[i], items[j], query.SortBy) + if cmp == 0 { + cmp = strings.Compare(util.Clean(items[i]["id"]), util.Clean(items[j]["id"])) + } + if desc { + return cmp > 0 + } + return cmp < 0 + }) +} + +func compareManagedUsers(left, right map[string]any, sortBy string) int { + switch sortBy { + case "enabled": + return compareManagedUserInts(managedUserSortBool(left, sortBy), managedUserSortBool(right, sortBy)) + case "billing_available", "call_count", "quota_used", "failure_count": + return compareManagedUserInts(managedUserSortInt(left, sortBy), managedUserSortInt(right, sortBy)) + default: + return strings.Compare(strings.ToLower(managedUserSortString(left, sortBy)), strings.ToLower(managedUserSortString(right, sortBy))) + } +} + +func managedUserSortString(item map[string]any, sortBy string) string { + switch sortBy { + case "name": + return util.Clean(item["name"]) + case "username": + return util.Clean(item["username"]) + case "provider": + return util.Clean(item["provider"]) + case "role_id": + return util.Clean(item["role_id"]) + case "role_name": + return util.Clean(item["role_name"]) + case "created_at": + return util.Clean(item["created_at"]) + case "last_used_at": + return util.Clean(item["last_used_at"]) + case "updated_at": + return util.Clean(item["updated_at"]) + default: + return util.Clean(item["id"]) + } +} + +func managedUserSortBool(item map[string]any, sortBy string) int { + if sortBy == "enabled" && util.ToBool(item["enabled"]) { + return 1 + } + return 0 +} + +func managedUserSortInt(item map[string]any, sortBy string) int { + if sortBy == "billing_available" { + return util.ToInt(util.StringMap(item["billing"])["available"], 0) + } + return util.ToInt(item[sortBy], 0) +} + +func compareManagedUserInts(left, right int) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +func managedUserSearchText(item map[string]any) string { + parts := []string{ + util.Clean(item["id"]), + util.Clean(item["username"]), + util.Clean(item["name"]), + util.Clean(item["role_id"]), + util.Clean(item["role_name"]), + util.Clean(item["owner_id"]), + util.Clean(item["owner_name"]), + util.Clean(item["provider"]), + util.Clean(item["linuxdo_level"]), + util.Clean(item["session_id"]), + util.Clean(item["session_name"]), + } + return strings.ToLower(strings.Join(parts, " ")) } func findManagedUser(items []map[string]any, id string) map[string]any { @@ -614,6 +1146,25 @@ func (a *App) handleAccounts(w http.ResponseWriter, r *http.Request) { util.WriteJSON(w, http.StatusOK, map[string]any{"items": a.accountItemsForIdentity(identity)}) case r.URL.Path == "/api/accounts/tokens" && r.Method == http.MethodGet: util.WriteJSON(w, http.StatusOK, map[string]any{"tokens": a.accounts.ListTokens()}) + case r.URL.Path == "/api/accounts/session" && r.Method == http.MethodPost: + body, err := readJSONMap(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, "invalid json body") + return + } + sessionJSON := util.Clean(body["session_json"]) + if sessionJSON == "" { + util.WriteError(w, http.StatusBadRequest, "session_json is required") + return + } + result, err := a.accounts.AddAccountFromSession(sessionJSON) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + delete(result, "tokens") + a.redactAccountPayloadForIdentity(identity, result) + util.WriteJSON(w, http.StatusOK, result) case r.URL.Path == "/api/accounts" && r.Method == http.MethodPost: body, _ := readJSONMap(r) tokens := util.AsStringSlice(body["tokens"]) @@ -965,7 +1516,7 @@ func (a *App) handleCreationTasks(w http.ResponseWriter, r *http.Request) { } if r.URL.Path == "/api/creation-tasks/chat-completions" && r.Method == http.MethodPost { body, _ := readJSONMap(r) - task, err := a.tasks.SubmitChat(r.Context(), identity, util.Clean(body["client_task_id"]), util.Clean(body["prompt"]), firstNonEmpty(util.Clean(body["model"]), util.ImageModelAuto), body["messages"]) + task, err := a.tasks.SubmitChat(r.Context(), identity, util.Clean(body["client_task_id"]), util.Clean(body["prompt"]), firstNonEmpty(util.Clean(body["model"]), util.ImageModelAuto), body["messages"], protocol.IsImageChatRequest(body), util.ToInt(body["n"], 1)) if err != nil { writeCreationTaskSubmitError(w, err) return @@ -999,6 +1550,18 @@ func imageTaskRequestMetadata(body map[string]any) map[string]any { if size != "" { metadata["requested_size"] = size } + if util.ToBool(body["share_prompt_parameters"]) { + metadata["share_prompt_parameters"] = true + if util.ToBool(body["share_reference_images"]) { + metadata["share_reference_images"] = true + } + } + if conversationID := util.Clean(body["frontend_conversation_id"]); conversationID != "" { + metadata["frontend_conversation_id"] = conversationID + } + if fallback := util.StringMap(body["fallback_reference_image"]); len(fallback) > 0 { + metadata["fallback_reference_image"] = fallback + } return metadata } @@ -1041,6 +1604,11 @@ func imageOutputCompressionFromBody(value any) (int, bool) { } func writeCreationTaskSubmitError(w http.ResponseWriter, err error) { + var billingErr service.BillingLimitError + if errors.As(err, &billingErr) { + util.WriteJSON(w, http.StatusTooManyRequests, billingErr.OpenAIError()) + return + } var limitErr service.ImageTaskLimitError if errors.As(err, &limitErr) { util.WriteError(w, http.StatusTooManyRequests, limitErr.Error()) diff --git a/cmd/chatgpt2api/main.go b/internal/main.go similarity index 100% rename from cmd/chatgpt2api/main.go rename to internal/main.go diff --git a/internal/protocol/api.go b/internal/protocol/api.go index 86a5d0a20..a4433ac45 100644 --- a/internal/protocol/api.go +++ b/internal/protocol/api.go @@ -5,12 +5,13 @@ import ( "encoding/base64" "encoding/json" "fmt" - "html" "regexp" "strings" "time" "chatgpt2api/internal/backend" + "chatgpt2api/internal/service" + tooladapter "chatgpt2api/internal/toolcall" "chatgpt2api/internal/util" ) @@ -22,6 +23,13 @@ type StreamResult struct { const ImageOutputSlotAcquirerPayloadKey = "image_output_slot_acquirer" +// ImageOutputChargePayloadKey names the per-image-output billing charge hook +// carried through the request body. The value must be an ImageOutputCharger +// (or a compatible func(index int) error). The hook runs before each image +// is persisted to disk and can veto the save by returning an error; returning +// a service.BillingLimitError propagates as the request-level error. +const ImageOutputChargePayloadKey = "image_output_charge" + const xmlToolRule = "Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:\nTOOL_NAME" func (e *Engine) HandleImageGenerations(ctx context.Context, body map[string]any) (map[string]any, *StreamResult, error) { @@ -40,7 +48,7 @@ func (e *Engine) HandleImageGenerations(ctx context.Context, body map[string]any outputCompression, hasOutputCompression := normalizedImageOutputCompression(body["output_compression"]) responseFormat := firstNonEmpty(util.Clean(body["response_format"]), "b64_json") baseURL := util.Clean(body["base_url"]) - request := ConversationRequest{Prompt: prompt, Model: model, Messages: NormalizeMessages(util.AsMapSlice(body["messages"]), nil), N: n, Size: size, Quality: quality, Background: util.Clean(body["background"]), Moderation: util.Clean(body["moderation"]), Style: util.Clean(body["style"]), OutputFormat: outputFormat, ResponseFormat: responseFormat, BaseURL: baseURL, OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), MessageAsError: true, AcquireImageOutputSlot: imageOutputSlotAcquirer(body)} + request := ConversationRequest{Prompt: prompt, Model: model, Messages: NormalizeMessages(util.AsMapSlice(body["messages"]), nil), N: n, Size: size, Quality: quality, Background: util.Clean(body["background"]), Moderation: util.Clean(body["moderation"]), Style: util.Clean(body["style"]), OutputFormat: outputFormat, ResponseFormat: responseFormat, BaseURL: baseURL, OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), FrontendConversationID: util.Clean(body["frontend_conversation_id"]), FallbackReferenceImage: util.Clean(body["fallback_reference_image_b64"]), MessageAsError: true, AcquireImageOutputSlot: imageOutputSlotAcquirer(body), ChargeImageOutput: imageOutputCharger(body)} if partialImages, ok := normalizedPositiveInt(body["partial_images"]); ok { request.PartialImages = &partialImages } @@ -77,11 +85,14 @@ func (e *Engine) HandleImageEdits(ctx context.Context, body map[string]any, imag BaseURL: util.Clean(body["base_url"]), OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), + FrontendConversationID: util.Clean(body["frontend_conversation_id"]), + FallbackReferenceImage: util.Clean(body["fallback_reference_image_b64"]), Messages: NormalizeMessages(util.AsMapSlice(body["messages"]), nil), Images: encoded, InputImageMask: responseImageMask(body["input_image_mask"]), MessageAsError: true, AcquireImageOutputSlot: imageOutputSlotAcquirer(body), + ChargeImageOutput: imageOutputCharger(body), } if partialImages, ok := normalizedPositiveInt(body["partial_images"]); ok { request.PartialImages = &partialImages @@ -123,6 +134,17 @@ func imageOutputSlotAcquirer(body map[string]any) ImageOutputSlotAcquirer { } } +func imageOutputCharger(body map[string]any) ImageOutputCharger { + switch charge := body[ImageOutputChargePayloadKey].(type) { + case ImageOutputCharger: + return charge + case func(int) error: + return charge + default: + return nil + } +} + func StreamImageChunks(outputs <-chan ImageOutput) <-chan map[string]any { out := make(chan map[string]any) go func() { @@ -134,33 +156,204 @@ func StreamImageChunks(outputs <-chan ImageOutput) <-chan map[string]any { return out } +func (e *Engine) textBackendWithRetry(exhaustedTokens map[string]struct{}) (*backend.Client, string, bool) { + token, ok := e.Accounts.GetTextAccessTokenWithRetry(exhaustedTokens) + if !ok { + return nil, "", false + } + return e.TextBackend(token), token, true +} + +func (e *Engine) markTextTokenExpiredForRetry(accessToken string, err error, exhaustedTokens map[string]struct{}) bool { + if err == nil || !service.IsAccountTokenExpiredErrorMessage(err.Error()) { + return false + } + exhaustedTokens[accessToken] = struct{}{} + if _, shouldRetry := e.Accounts.HandleTokenExpiredOnRequest(accessToken); shouldRetry { + return true + } + e.Accounts.ApplyAccountError(accessToken, "text_stream", err) + return false +} + +func (e *Engine) streamTextDeltasWithTokenRetry(ctx context.Context, firstClient *backend.Client, request ConversationRequest) (<-chan string, <-chan error) { + out := make(chan string) + errOut := make(chan error, 1) + go func() { + defer close(out) + defer close(errOut) + exhaustedTokens := map[string]struct{}{} + client := firstClient + var lastErr error + for attempt := 0; attempt < service.MaxTokenSwitchAttempts; attempt++ { + if client == nil { + var ok bool + client, _, ok = e.textBackendWithRetry(exhaustedTokens) + if !ok { + break + } + } + deltas, upstreamErr := e.StreamTextDeltas(ctx, client, request) + sent := false + for delta := range deltas { + sent = true + select { + case out <- delta: + case <-ctx.Done(): + errOut <- ctx.Err() + return + } + } + err := <-upstreamErr + if err == nil { + errOut <- nil + return + } + lastErr = err + if sent || !e.markTextTokenExpiredForRetry(client.AccessToken, err, exhaustedTokens) { + errOut <- err + return + } + client = nil + } + if lastErr != nil { + errOut <- lastErr + return + } + errOut <- fmt.Errorf("no available text access token") + }() + return out, errOut +} + +func (e *Engine) collectTextWithTokenRetry(ctx context.Context, request ConversationRequest) (string, error) { + deltas, errCh := e.streamTextDeltasWithTokenRetry(ctx, nil, request) + var parts []string + for delta := range deltas { + parts = append(parts, delta) + } + return strings.Join(parts, ""), <-errCh +} + +func (e *Engine) collectVisionTextWithTokenRetry(ctx context.Context, messages []map[string]any, model string, images []backend.VisionImage) (string, error) { + exhaustedTokens := map[string]struct{}{} + var lastErr error + for attempt := 0; attempt < service.MaxTokenSwitchAttempts; attempt++ { + client, token, ok := e.textBackendWithRetry(exhaustedTokens) + if !ok { + break + } + text, err := e.CollectVisionText(ctx, client, messages, model, images) + if err == nil { + return text, nil + } + lastErr = err + if !e.markTextTokenExpiredForRetry(token, err, exhaustedTokens) { + return "", err + } + } + if lastErr != nil { + return "", lastErr + } + return "", fmt.Errorf("no available text access token") +} + +func (e *Engine) streamVisionDeltasWithTokenRetry(ctx context.Context, firstClient *backend.Client, messages []map[string]any, model string, images []backend.VisionImage) (<-chan string, <-chan error) { + out := make(chan string) + errOut := make(chan error, 1) + go func() { + defer close(out) + defer close(errOut) + exhaustedTokens := map[string]struct{}{} + client := firstClient + var lastErr error + for attempt := 0; attempt < service.MaxTokenSwitchAttempts; attempt++ { + if client == nil { + var ok bool + client, _, ok = e.textBackendWithRetry(exhaustedTokens) + if !ok { + break + } + } + deltas, upstreamErr := client.StreamMultimodalConversation(ctx, messages, model, images) + sent := false + for delta := range deltas { + sent = true + select { + case out <- delta: + case <-ctx.Done(): + errOut <- ctx.Err() + return + } + } + err := <-upstreamErr + if err == nil { + errOut <- nil + return + } + lastErr = err + if sent || !e.markTextTokenExpiredForRetry(client.AccessToken, err, exhaustedTokens) { + errOut <- err + return + } + client = nil + } + if lastErr != nil { + errOut <- lastErr + return + } + errOut <- fmt.Errorf("no available text access token") + }() + return out, errOut +} + func (e *Engine) HandleChatCompletions(ctx context.Context, body map[string]any) (map[string]any, *StreamResult, error) { if util.ToBool(body["stream"]) { var items <-chan map[string]any var errCh <-chan error if IsImageChatRequest(body) { items, errCh = e.ImageChatEvents(ctx, body) + } else if HasVisionImages(body) { + model, messages, images, err := VisionChatParts(body) + if err != nil { + return nil, nil, err + } + items, errCh = e.StreamVisionChatCompletionWithTools(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), messages, model, images, body["tools"], body["tool_choice"]) } else { model, messages, err := TextChatParts(body) if err != nil { return nil, nil, err } - items, errCh = e.StreamTextChatCompletion(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), messages, model) + items, errCh = e.StreamTextChatCompletionWithTools(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), messages, model, body["tools"], body["tool_choice"]) } return nil, &StreamResult{Items: items, Err: errCh, Kind: "openai"}, nil } if IsImageChatRequest(body) { return e.ImageChatResponse(ctx, body) } + if HasVisionImages(body) { + model, messages, images, err := VisionChatParts(body) + if err != nil { + return nil, nil, err + } + result, err := e.VisionChatResponse(ctx, body, model, messages, images) + if err != nil { + return nil, nil, err + } + return result, nil, nil + } model, messages, err := TextChatParts(body) if err != nil { return nil, nil, err } - text, err := e.CollectText(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), ConversationRequest{Model: model, Messages: messages}) + text, err := e.collectTextWithTokenRetry(ctx, ConversationRequest{Model: model, Messages: messages}) + if err != nil { + return nil, nil, err + } + result, err := CompletionResponseWithTools(model, text, 0, messages, body["tools"], body["tool_choice"]) if err != nil { return nil, nil, err } - return CompletionResponse(model, text, 0, messages), nil, nil + return result, nil, nil } func CompletionChunk(model string, delta map[string]any, finishReason any, completionID string, created int64) map[string]any { @@ -189,37 +382,245 @@ func CompletionResponse(model, content string, created int64, messages []map[str } } -func (e *Engine) StreamTextChatCompletion(ctx context.Context, client *backend.Client, messages []map[string]any, model string) (<-chan map[string]any, <-chan error) { +func CompletionResponseWithTools(model, content string, created int64, messages []map[string]any, tools any, choice any) (map[string]any, error) { + policy := tooladapter.PolicyFromToolChoice(choice) + toolNames := tooladapter.ToolNames(tools) + if policy.Mode == tooladapter.ChoiceNone { + return CompletionResponse(model, content, created, messages), nil + } + if err := validateToolChoice(policy, toolNames); err != nil { + return nil, HTTPError{Status: 400, Message: err.Error()} + } + if len(toolNames) == 0 { + return CompletionResponse(model, content, created, messages), nil + } + calls, visible, err := tooladapter.Parse(content, toolNames, policy) + if err != nil { + return nil, HTTPError{Status: 400, Message: err.Error()} + } + calls = tooladapter.NormalizeForSchemas(calls, tools) + if len(calls) == 0 { + return CompletionResponse(model, visible, created, messages), nil + } + response := CompletionResponse(model, "", created, messages) + choiceMap := response["choices"].([]map[string]any)[0] + message := choiceMap["message"].(map[string]any) + message["content"] = nil + message["tool_calls"] = tooladapter.FormatOpenAI(calls) + choiceMap["finish_reason"] = "tool_calls" + return response, nil +} + +func streamChatCompletionEvents(ctx context.Context, model string, deltas <-chan string, upstreamErr <-chan error, tools any, choice any) (<-chan map[string]any, <-chan error) { + policy := tooladapter.PolicyFromToolChoice(choice) + toolNames := tooladapter.ToolNames(tools) + if err := preflightToolChoiceWithoutToolsError(policy, toolNames); err != nil { + return streamHTTPError(err) + } out := make(chan map[string]any) errOut := make(chan error, 1) go func() { defer close(out) defer close(errOut) - deltas, errCh := e.StreamTextDeltas(ctx, client, ConversationRequest{Model: model, Messages: messages}) id := "chatcmpl-" + util.NewHex(32) created := time.Now().Unix() + toolMode := len(toolNames) > 0 && policy.Mode != tooladapter.ChoiceNone + current := "" + streamedLen := 0 sentRole := false - for deltaText := range deltas { + + send := func(chunk map[string]any) bool { + select { + case out <- chunk: + return true + case <-ctx.Done(): + errOut <- ctx.Err() + return false + } + } + sendText := func(text string) bool { + if text == "" { + return true + } if !sentRole { sentRole = true - out <- CompletionChunk(model, map[string]any{"role": "assistant", "content": deltaText}, nil, id, created) - } else { - out <- CompletionChunk(model, map[string]any{"content": deltaText}, nil, id, created) + return send(CompletionChunk(model, map[string]any{"role": "assistant", "content": text}, nil, id, created)) } + return send(CompletionChunk(model, map[string]any{"content": text}, nil, id, created)) } - if err := <-errCh; err != nil { - errOut <- err + sendRoleIfNeeded := func() bool { + if sentRole { + return true + } + sentRole = true + return send(CompletionChunk(model, map[string]any{"role": "assistant", "content": ""}, nil, id, created)) + } + + for deltaText := range deltas { + current += deltaText + visible := current + if toolMode { + visible = safeRawToolVisiblePrefix(current) + } + if len(visible) >= streamedLen { + next := visible[streamedLen:] + if next != "" { + if !sendText(next) { + return + } + streamedLen = len(visible) + } + } + } + if upstreamErr != nil { + if err := <-upstreamErr; err != nil { + errOut <- err + return + } + } + if toolMode { + calls, _, err := tooladapter.Parse(current, toolNames, policy) + if err != nil { + errOut <- HTTPError{Status: 400, Message: err.Error()} + return + } + calls = tooladapter.NormalizeForSchemas(calls, tools) + if len(calls) > 0 { + if !sendRoleIfNeeded() { + return + } + if !send(CompletionChunk(model, map[string]any{"tool_calls": tooladapter.FormatOpenAIStream(calls)}, "tool_calls", id, created)) { + return + } + errOut <- nil + return + } + if streamedLen <= len(current) { + if !sendText(current[streamedLen:]) { + return + } + } + } + if !sendRoleIfNeeded() { return } - if !sentRole { - out <- CompletionChunk(model, map[string]any{"role": "assistant", "content": ""}, nil, id, created) + if !send(CompletionChunk(model, map[string]any{}, "stop", id, created)) { + return } - out <- CompletionChunk(model, map[string]any{}, "stop", id, created) errOut <- nil }() return out, errOut } +func streamHTTPError(err error) (<-chan map[string]any, <-chan error) { + out := make(chan map[string]any) + errOut := make(chan error, 1) + go func() { + defer close(out) + defer close(errOut) + errOut <- HTTPError{Status: 400, Message: err.Error()} + }() + return out, errOut +} + +func safeRawToolVisiblePrefix(text string) string { + masked := maskFencedToolBlocks(text) + markerStart := -1 + for _, marker := range toolMarkupMarkers { + if pos := strings.Index(masked, marker); pos >= 0 && (markerStart < 0 || pos < markerStart) { + markerStart = pos + } + } + if markerStart >= 0 { + return text[:markerStart] + } + for keep := len(text) - 1; keep >= 0; keep-- { + suffix := text[keep:] + for _, marker := range toolMarkupMarkers { + if strings.HasPrefix(marker, suffix) { + return text[:keep] + } + } + } + return text +} + +var toolMarkupMarkers = []string{" 0 { return messages, nil @@ -230,13 +631,17 @@ func ChatMessagesFromBody(body map[string]any) ([]map[string]any, error) { return nil, HTTPError{Status: 400, Message: "messages or prompt is required"} } +func ChatToolPrompt(body map[string]any) string { + return tooladapter.BuildPrompt(body["tools"], tooladapter.PolicyFromToolChoice(body["tool_choice"])) +} + func TextChatParts(body map[string]any) (string, []map[string]any, error) { model := firstNonEmpty(util.Clean(body["model"]), "auto") messages, err := ChatMessagesFromBody(body) if err != nil { return "", nil, err } - return model, NormalizeMessages(messages, nil), nil + return model, NormalizeMessages(messages, ChatToolPrompt(body)), nil } func IsImageChatRequest(body map[string]any) bool { @@ -251,13 +656,44 @@ func IsImageChatRequest(body map[string]any) bool { return false } +func HasVisionImages(body map[string]any) bool { + if IsImageChatRequest(body) { + return false + } + for _, msg := range util.AsMapSlice(body["messages"]) { + if len(ExtractImagesFromMessageContent(msg["content"])) > 0 { + return true + } + } + return false +} + +func ExtractVisionImages(body map[string]any) []UploadedImage { + var images []UploadedImage + for _, msg := range util.AsMapSlice(body["messages"]) { + images = append(images, ExtractImagesFromMessageContent(msg["content"])...) + } + return images +} + +func VisionChatParts(body map[string]any) (string, []map[string]any, []UploadedImage, error) { + model := firstNonEmpty(util.Clean(body["model"]), "auto") + rawMessages, err := ChatMessagesFromBody(body) + if err != nil { + return "", nil, nil, err + } + messages := NormalizeMessages(rawMessages, ChatToolPrompt(body)) + images := ExtractVisionImages(body) + return model, messages, images, nil +} + func (e *Engine) ImageChatResponse(ctx context.Context, body map[string]any) (map[string]any, *StreamResult, error) { model, prompt, n, images, messages, err := ChatImageArgs(body) if err != nil { return nil, nil, err } size := util.Clean(body["size"]) - request := ConversationRequest{Prompt: prompt, Model: model, Messages: messages, N: n, Size: size, Quality: util.Clean(body["quality"]), Background: util.Clean(body["background"]), Moderation: util.Clean(body["moderation"]), Style: util.Clean(body["style"]), ResponseFormat: "b64_json", OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), Images: EncodeImages(images), InputImageMask: responseImageMask(body["input_image_mask"]), AcquireImageOutputSlot: imageOutputSlotAcquirer(body)} + request := ConversationRequest{Prompt: prompt, Model: model, Messages: messages, N: n, Size: size, Quality: util.Clean(body["quality"]), Background: util.Clean(body["background"]), Moderation: util.Clean(body["moderation"]), Style: util.Clean(body["style"]), ResponseFormat: "b64_json", OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), Images: EncodeImages(images), InputImageMask: responseImageMask(body["input_image_mask"]), AcquireImageOutputSlot: imageOutputSlotAcquirer(body), ChargeImageOutput: imageOutputCharger(body)} if partialImages, ok := normalizedPositiveInt(body["partial_images"]); ok { request.PartialImages = &partialImages } @@ -283,7 +719,7 @@ func (e *Engine) ImageChatEvents(ctx context.Context, body map[string]any) (<-ch return } size := util.Clean(body["size"]) - request := ConversationRequest{Prompt: prompt, Model: model, Messages: messages, N: n, Size: size, Quality: util.Clean(body["quality"]), Background: util.Clean(body["background"]), Moderation: util.Clean(body["moderation"]), Style: util.Clean(body["style"]), ResponseFormat: "b64_json", OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), Images: EncodeImages(images), InputImageMask: responseImageMask(body["input_image_mask"]), AcquireImageOutputSlot: imageOutputSlotAcquirer(body)} + request := ConversationRequest{Prompt: prompt, Model: model, Messages: messages, N: n, Size: size, Quality: util.Clean(body["quality"]), Background: util.Clean(body["background"]), Moderation: util.Clean(body["moderation"]), Style: util.Clean(body["style"]), ResponseFormat: "b64_json", OwnerID: util.Clean(body["owner_id"]), OwnerName: util.Clean(body["owner_name"]), Images: EncodeImages(images), InputImageMask: responseImageMask(body["input_image_mask"]), AcquireImageOutputSlot: imageOutputSlotAcquirer(body), ChargeImageOutput: imageOutputCharger(body)} if partialImages, ok := normalizedPositiveInt(body["partial_images"]); ok { request.PartialImages = &partialImages } @@ -567,6 +1003,7 @@ func (e *Engine) ResponseEventsScoped(ctx context.Context, body map[string]any, return nil, nil, err } request.AcquireImageOutputSlot = imageOutputSlotAcquirer(body) + request.ChargeImageOutput = imageOutputCharger(body) var currentImages []string if inputImages := ExtractResponseImages(body["input"]); len(inputImages) > 0 { currentImages = EncodeImages(inputImages) @@ -659,7 +1096,7 @@ func (e *Engine) StreamTextResponse(ctx context.Context, body map[string]any) (< } func (e *Engine) StreamTextResponseWithMessages(ctx context.Context, model string, messages []map[string]any) (<-chan map[string]any, <-chan error) { - deltas, errCh := e.StreamTextDeltas(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), ConversationRequest{Model: model, Messages: messages}) + deltas, errCh := e.streamTextDeltasWithTokenRetry(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), ConversationRequest{Model: model, Messages: messages}) return streamTextResponseEvents(ctx, model, deltas, errCh) } @@ -767,7 +1204,7 @@ func StreamImageResponse(outputs <-chan ImageOutput, prompt, model string) (<-ch return } } - errCh <- fmt.Errorf("image generation failed") + errCh <- fmt.Errorf("upstream image stream completed without image output") }() return out, errCh } @@ -973,42 +1410,30 @@ func (e *Engine) HandleMessages(ctx context.Context, body map[string]any) (map[s if err := <-errCh; err != nil { return nil, nil, err } - return MessageResponse(request.Model, text, CountMessageTokens(request.Messages, request.Model), CountTextTokens(text, request.Model), request.Tools), nil, nil + response, err := MessageResponseWithChoice(request.Model, text, CountMessageTokens(request.Messages, request.Model), CountTextTokens(text, request.Model), request.Tools, request.ToolChoice) + if err != nil { + return nil, nil, err + } + return response, nil, nil } type MessageRequest struct { - Messages []map[string]any - Model string - Tools any + Messages []map[string]any + Model string + Tools any + ToolChoice any } func MessageRequestFromBody(e *Engine, body map[string]any) MessageRequest { payload := util.CopyMap(body) + policy := tooladapter.PolicyFromToolChoice(payload["tool_choice"]) payload["messages"] = PreprocessMessages(payload["messages"]) - payload["system"] = MergeSystem(payload["system"], BuildToolPrompt(payload["tools"])) - return MessageRequest{Messages: NormalizeMessages(payload["messages"], payload["system"]), Model: firstNonEmpty(util.Clean(payload["model"]), "auto"), Tools: payload["tools"]} + payload["system"] = MergeSystem(payload["system"], tooladapter.BuildPrompt(payload["tools"], policy)) + return MessageRequest{Messages: NormalizeMessages(payload["messages"], payload["system"]), Model: firstNonEmpty(util.Clean(payload["model"]), "auto"), Tools: payload["tools"], ToolChoice: payload["tool_choice"]} } func BuildToolPrompt(tools any) string { - var blocks []string - for _, raw := range anyList(tools) { - tool, ok := raw.(map[string]any) - if !ok { - continue - } - fn := util.StringMap(tool["function"]) - name := firstNonEmpty(util.Clean(tool["name"]), util.Clean(fn["name"])) - desc := firstNonEmpty(util.Clean(tool["description"]), util.Clean(fn["description"])) - schema := firstNonNil(tool["input_schema"], tool["parameters"], fn["input_schema"], fn["parameters"], map[string]any{}) - if name != "" { - data, _ := json.Marshal(schema) - blocks = append(blocks, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(data))) - } - } - if len(blocks) == 0 { - return "" - } - return "Available tools:\n" + strings.Join(blocks, "\n") + "\n\nTool use rules:\n- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.\n- To call tools, output ONLY XML and no prose/markdown:\nTOOL_NAME\n- Put parameters under using the exact schema names." + return tooladapter.BuildPrompt(tools, tooladapter.ChoicePolicy{Mode: tooladapter.ChoiceAuto}) } func MergeSystem(system any, extra string) any { @@ -1119,31 +1544,101 @@ func preprocessBlock(block any) any { } func MessageResponse(model, text string, inputTokens, outputTokens int, tools any) map[string]any { - content, stopReason := ContentBlocks(text, tools) - return map[string]any{"id": "msg_" + util.NewUUID(), "type": "message", "role": "assistant", "model": model, "content": content, "stop_reason": stopReason, "stop_sequence": nil, "usage": map[string]any{"input_tokens": inputTokens, "output_tokens": outputTokens}} + response, err := MessageResponseWithChoice(model, text, inputTokens, outputTokens, tools, nil) + if err != nil { + return map[string]any{"id": "msg_" + util.NewUUID(), "type": "message", "role": "assistant", "model": model, "content": []map[string]any{{"type": "text", "text": StripToolMarkup(text)}}, "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": inputTokens, "output_tokens": outputTokens}} + } + return response +} + +func MessageResponseWithChoice(model, text string, inputTokens, outputTokens int, tools any, choice any) (map[string]any, error) { + content, stopReason, err := ContentBlocksWithChoice(text, tools, choice) + if err != nil { + return nil, HTTPError{Status: 400, Message: err.Error()} + } + return map[string]any{"id": "msg_" + util.NewUUID(), "type": "message", "role": "assistant", "model": model, "content": content, "stop_reason": stopReason, "stop_sequence": nil, "usage": map[string]any{"input_tokens": inputTokens, "output_tokens": outputTokens}}, nil } func ContentBlocks(text string, tools any) ([]map[string]any, string) { - var calls []ToolCall - if len(anyList(tools)) > 0 { - calls = ParseToolCalls(text) + content, stopReason, err := ContentBlocksWithChoice(text, tools, nil) + if err != nil { + return []map[string]any{{"type": "text", "text": StripToolMarkup(text)}}, "end_turn" + } + return content, stopReason +} + +func noToolsForRequiredChoiceError(policy tooladapter.ChoicePolicy) error { + if policy.Mode == tooladapter.ChoiceRequired || policy.Mode == tooladapter.ChoiceForced { + return fmt.Errorf("tool_choice %s requires at least one available tool", policy.Mode) + } + return nil +} + +func validateToolChoice(policy tooladapter.ChoicePolicy, toolNames []string) error { + if len(toolNames) == 0 { + return noToolsForRequiredChoiceError(policy) + } + if policy.Mode == tooladapter.ChoiceForced && policy.Name != "" && !hasToolName(toolNames, policy.Name) { + return fmt.Errorf("tool_choice forced %s is not an available tool", policy.Name) + } + return nil +} + +func hasToolName(toolNames []string, name string) bool { + name = strings.TrimSpace(name) + for _, toolName := range toolNames { + if strings.TrimSpace(toolName) == name { + return true + } + } + return false +} + +func preflightToolChoiceWithoutToolsError(policy tooladapter.ChoicePolicy, toolNames []string) error { + return validateToolChoice(policy, toolNames) +} + +func ContentBlocksWithChoice(text string, tools any, choice any) ([]map[string]any, string, error) { + policy := tooladapter.PolicyFromToolChoice(choice) + toolNames := tooladapter.ToolNames(tools) + if policy.Mode == tooladapter.ChoiceNone { + return []map[string]any{{"type": "text", "text": text}}, "end_turn", nil + } + if err := validateToolChoice(policy, toolNames); err != nil { + return nil, "", err + } + if len(toolNames) == 0 { + return []map[string]any{{"type": "text", "text": text}}, "end_turn", nil } - text = StripToolMarkup(text) + + calls, visible, err := tooladapter.Parse(text, toolNames, policy) + if err != nil { + return nil, "", err + } + calls = tooladapter.NormalizeForSchemas(calls, tools) if len(calls) == 0 { - return []map[string]any{{"type": "text", "text": text}}, "end_turn" + return []map[string]any{{"type": "text", "text": visible}}, "end_turn", nil } + var content []map[string]any - if text != "" { - content = append(content, map[string]any{"type": "text", "text": text}) - } - for _, call := range calls { - content = append(content, map[string]any{"type": "tool_use", "id": "toolu_" + util.NewUUID(), "name": call.Name, "input": call.Input}) + if visible != "" { + content = append(content, map[string]any{"type": "text", "text": visible}) } - return content, "tool_use" + content = append(content, tooladapter.FormatAnthropic(calls)...) + return content, "tool_use", nil +} + +var streamTextChatCompletionForAnthropic = func(ctx context.Context, e *Engine, request MessageRequest) (<-chan map[string]any, <-chan error) { + return e.StreamTextChatCompletion(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), request.Messages, request.Model) } func (e *Engine) StreamAnthropicEvents(ctx context.Context, request MessageRequest) (<-chan map[string]any, <-chan error) { - chunks, errCh := e.StreamTextChatCompletion(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), request.Messages, request.Model) + policy := tooladapter.PolicyFromToolChoice(request.ToolChoice) + toolNames := tooladapter.ToolNames(request.Tools) + if err := validateToolChoice(policy, toolNames); err != nil { + return streamHTTPError(err) + } + chunks, errCh := streamTextChatCompletionForAnthropic(ctx, e, request) out := make(chan map[string]any) outErr := make(chan error, 1) go func() { @@ -1151,8 +1646,8 @@ func (e *Engine) StreamAnthropicEvents(ctx context.Context, request MessageReque defer close(outErr) messageID := "msg_" + util.NewUUID() current := "" - streamed := "" - toolMode := len(anyList(request.Tools)) > 0 + streamedLen := 0 + toolMode := len(toolNames) > 0 && policy.Mode != tooladapter.ChoiceNone toolStarted := false textOpen := false out <- map[string]any{"type": "message_start", "message": map[string]any{"id": messageID, "type": "message", "role": "assistant", "model": request.Model, "content": []any{}, "stop_reason": nil, "stop_sequence": nil, "usage": map[string]any{"input_tokens": CountMessageTokens(request.Messages, request.Model), "output_tokens": 0}}} @@ -1163,22 +1658,25 @@ func (e *Engine) StreamAnthropicEvents(ctx context.Context, request MessageReque for chunk := range chunks { choice := firstChoice(chunk) delta := util.StringMap(choice["delta"]) - textDelta := util.Clean(delta["content"]) + textDelta, _ := delta["content"].(string) + if textDelta == "" { + textDelta = util.Clean(delta["content"]) + } if textDelta != "" { current += textDelta if !toolStarted { visible := current if toolMode { - visible = StreamableText(current) + visible = safeRawToolVisiblePrefix(current) } - if strings.HasPrefix(visible, streamed) { - next := visible[len(streamed):] + if len(visible) >= streamedLen { + next := visible[streamedLen:] if next != "" { if !textOpen { textOpen = true out <- map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}} } - streamed = visible + streamedLen = len(visible) out <- map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": next}} } } @@ -1186,7 +1684,22 @@ func (e *Engine) StreamAnthropicEvents(ctx context.Context, request MessageReque } } if choice["finish_reason"] != nil { - content, stopReason := ContentBlocks(current, request.Tools) + content, stopReason, err := ContentBlocksWithChoice(current, request.Tools, request.ToolChoice) + if err != nil { + outErr <- HTTPError{Status: 400, Message: err.Error()} + return + } + if stopReason == "end_turn" && streamedLen <= len(current) { + remaining := current[streamedLen:] + if remaining != "" { + if !textOpen { + textOpen = true + out <- map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}} + } + out <- map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": remaining}} + streamedLen = len(current) + } + } if textOpen { out <- map[string]any{"type": "content_block_stop", "index": 0} } @@ -1194,6 +1707,7 @@ func (e *Engine) StreamAnthropicEvents(ctx context.Context, request MessageReque startIndex := 0 if textOpen { startIndex = 1 + content = toolUseBlocks(content) } outBufferedBlocks(out, content, startIndex) } @@ -1211,6 +1725,16 @@ func (e *Engine) StreamAnthropicEvents(ctx context.Context, request MessageReque return out, outErr } +func toolUseBlocks(content []map[string]any) []map[string]any { + out := make([]map[string]any, 0, len(content)) + for _, block := range content { + if block["type"] == "tool_use" { + out = append(out, block) + } + } + return out +} + func outBufferedBlocks(out chan<- map[string]any, content []map[string]any, startIndex int) { for offset, block := range content { index := startIndex + offset @@ -1255,75 +1779,25 @@ type ToolCall struct { } func StripToolMarkup(text string) string { - return strings.TrimSpace(regexp.MustCompile(`(?is)]*>.*?|]*>.*?|]*>.*?|]*>.*?`).ReplaceAllString(text, "")) + return tooladapter.StripMarkup(text) } func StreamableText(text string) string { - loc := regexp.MustCompile(`(?is)]*>(.*?)|]*>(.*?)|]*>(.*?)`).FindAllStringSubmatch(text, -1) - var out []ToolCall - for _, match := range matches { - block := "" - for _, part := range match[1:] { - if part != "" { - block = part - break - } - } - name := firstNonEmpty(XMLValue(block, "tool_name"), XMLValue(block, "name"), XMLValue(block, "function")) - params := firstNonEmpty(XMLValue(block, "parameters"), XMLValue(block, "input"), XMLValue(block, "arguments"), "{}") - if name != "" { - out = append(out, ToolCall{Name: name, Input: ParseToolParams(params)}) - } - } - return out -} - -func XMLValue(text, tag string) string { - re := regexp.MustCompile(`(?is)<` + regexp.QuoteMeta(tag) + `\b[^>]*>(.*?)`) - match := re.FindStringSubmatch(text) - if len(match) < 2 { - return "" - } - value := strings.TrimSpace(match[1]) - if cdata := regexp.MustCompile(`(?is)^$`).FindStringSubmatch(value); len(cdata) > 1 { - value = cdata[1] - } - return strings.TrimSpace(html.UnescapeString(value)) -} - -func ParseToolParams(raw string) map[string]any { - raw = strings.TrimSpace(raw) - var parsed map[string]any - if json.Unmarshal([]byte(raw), &parsed) == nil { - return parsed + calls, _, err := tooladapter.Parse(text, nil, tooladapter.ChoicePolicy{Mode: tooladapter.ChoiceAuto}) + if err != nil { + return nil } - out := map[string]any{} - for _, match := range regexp.MustCompile(`(?is)<([\w.-]+)\b[^>]*>(.*?)`).FindAllStringSubmatch(raw, -1) { - if len(match) > 3 && match[1] == match[3] { - out[match[1]] = ParseToolValue(match[2]) - } + out := make([]ToolCall, 0, len(calls)) + for _, call := range calls { + out = append(out, ToolCall{Name: call.Name, Input: call.Input}) } return out } -func ParseToolValue(raw string) any { - value := XMLValue(""+raw+"", "x") - var parsed any - if json.Unmarshal([]byte(value), &parsed) == nil { - return parsed - } - return value -} - func firstNonNil(values ...any) any { for _, value := range values { if value != nil { diff --git a/internal/protocol/api_test.go b/internal/protocol/api_test.go index db1ea850e..2ea9851bf 100644 --- a/internal/protocol/api_test.go +++ b/internal/protocol/api_test.go @@ -78,6 +78,525 @@ func TestTextModelDoesNotForceImageChatRoute(t *testing.T) { } } +func TestImageChatRouteStillWinsWhenToolsPresent(t *testing.T) { + body := map[string]any{ + "model": "gpt-image-2", + "messages": []any{map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "text", "text": "把这张图改成海报"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("png"))}}, + }}}, + "tools": []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "lookup_style", + "description": "lookup a style preset", + "parameters": map[string]any{"type": "object"}, + }, + }}, + } + + if !IsImageChatRequest(body) { + t.Fatal("image model with function tools should still use the image chat route") + } + if HasVisionImages(body) { + t.Fatal("image model with function tools should not be reclassified as a vision request") + } +} + +func TestMessageResponseUsesSharedToolUseBlocks(t *testing.T) { + tools := []any{map[string]any{ + "name": "read_file", + "description": "read a file", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }} + content := `read_file{"path":"internal/app.go"}` + + response := MessageResponse("claude", content, 10, 5, tools) + if response["stop_reason"] != "tool_use" { + t.Fatalf("stop_reason = %#v, want tool_use", response["stop_reason"]) + } + blocks := response["content"].([]map[string]any) + if len(blocks) != 1 || blocks[0]["type"] != "tool_use" || blocks[0]["name"] != "read_file" { + t.Fatalf("content blocks = %#v, want one read_file tool_use", blocks) + } +} + +func TestMessageRequestFromBodyHonorsToolChoiceNone(t *testing.T) { + tools := []any{map[string]any{"name": "read_file", "description": "read a file"}} + body := map[string]any{ + "model": "claude", + "tool_choice": "none", + "tools": tools, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + } + + request := MessageRequestFromBody(&Engine{}, body) + if request.ToolChoice != "none" { + t.Fatalf("ToolChoice = %#v, want none", request.ToolChoice) + } + for _, message := range request.Messages { + if strings.Contains(message["content"].(string), "Tool:") { + t.Fatalf("messages included injected tool prompt: %#v", request.Messages) + } + } +} + +func TestCompletionResponseWithToolCalls(t *testing.T) { + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "read_file", + "description": "read a file", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }, + }} + content := `read_file{"path":"internal/app.go"}` + + response, err := CompletionResponseWithTools("gpt-5", content, 123, []map[string]any{{"role": "user", "content": "read"}}, tools, nil) + if err != nil { + t.Fatalf("CompletionResponseWithTools() error = %v", err) + } + choice := response["choices"].([]map[string]any)[0] + if choice["finish_reason"] != "tool_calls" { + t.Fatalf("finish_reason = %#v, want tool_calls", choice["finish_reason"]) + } + message := choice["message"].(map[string]any) + if message["content"] != nil { + t.Fatalf("message.content = %#v, want nil", message["content"]) + } + toolCalls := message["tool_calls"].([]map[string]any) + if len(toolCalls) != 1 { + t.Fatalf("tool_calls = %#v, want one entry", toolCalls) + } + function := toolCalls[0]["function"].(map[string]any) + if function["name"] != "read_file" || !strings.Contains(function["arguments"].(string), "internal/app.go") { + t.Fatalf("function tool call = %#v", function) + } +} + +func TestCompletionResponseRequiredToolErrorsWithoutCall(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}} + _, err := CompletionResponseWithTools("gpt-5", "plain text", 123, nil, tools, "required") + var httpErr HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "tool_choice required") { + t.Fatalf("HTTPError = %#v, want status 400 with tool_choice required", httpErr) + } +} + +func TestCompletionResponseForcedToolRejectsExtraCall(t *testing.T) { + tools := []any{ + map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}, + map[string]any{"type": "function", "function": map[string]any{"name": "search"}}, + } + content := `read_file{}search{}` + choice := map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}} + + _, err := CompletionResponseWithTools("gpt-5", content, 123, nil, tools, choice) + var httpErr HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "model produced search") { + t.Fatalf("HTTPError = %#v, want forced tool mismatch", httpErr) + } +} + +func TestCompletionResponseForcedToolErrorsWhenToolMissing(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "search"}}} + choice := map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}} + + _, err := CompletionResponseWithTools("gpt-5", "plain text", 123, nil, tools, choice) + var httpErr HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "not an available tool") { + t.Fatalf("HTTPError = %#v, want missing forced tool error", httpErr) + } +} + +func TestStreamChatCompletionEventsEmitsFinalToolCalls(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}} + deltas := make(chan string, 2) + errs := make(chan error, 1) + deltas <- `read_file` + deltas <- `{"path":"internal/app.go"}` + close(deltas) + errs <- nil + close(errs) + + events, errCh := streamChatCompletionEvents(context.Background(), "gpt-5", deltas, errs, tools, nil) + var chunks []map[string]any + for event := range events { + chunks = append(chunks, event) + } + if err := <-errCh; err != nil { + t.Fatalf("streamChatCompletionEvents() error = %v", err) + } + if len(chunks) != 2 { + t.Fatalf("chunk count = %d, want role and final tool_calls chunks: %#v", len(chunks), chunks) + } + roleChoice := chunks[0]["choices"].([]map[string]any)[0] + roleDelta := roleChoice["delta"].(map[string]any) + if roleDelta["role"] != "assistant" { + t.Fatalf("first delta = %#v, want assistant role", roleDelta) + } + finalChoice := chunks[len(chunks)-1]["choices"].([]map[string]any)[0] + if finalChoice["finish_reason"] != "tool_calls" { + t.Fatalf("finish_reason = %#v, want tool_calls", finalChoice["finish_reason"]) + } + finalDelta := finalChoice["delta"].(map[string]any) + toolCalls := finalDelta["tool_calls"].([]map[string]any) + if len(toolCalls) != 1 { + t.Fatalf("tool_calls = %#v, want one entry", toolCalls) + } + function := toolCalls[0]["function"].(map[string]any) + if function["name"] != "read_file" || !strings.Contains(function["arguments"].(string), "internal/app.go") { + t.Fatalf("function tool call = %#v", function) + } +} + +func TestStreamChatCompletionEventsRequiredToolErrorsWithoutTools(t *testing.T) { + deltas := make(chan string, 1) + errs := make(chan error, 1) + deltas <- "normal text" + close(deltas) + errs <- nil + close(errs) + + events, errCh := streamChatCompletionEvents(context.Background(), "gpt-5", deltas, errs, nil, "required") + var chunks []map[string]any + for event := range events { + chunks = append(chunks, event) + } + if len(chunks) != 0 { + t.Fatalf("streamed chunks = %#v, want none", chunks) + } + var httpErr HTTPError + if err := <-errCh; !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "tool_choice required") { + t.Fatalf("HTTPError = %#v, want status 400 with tool_choice required", httpErr) + } +} + +func TestStreamChatCompletionEventsForcedToolErrorsWhenToolMissing(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "search"}}} + choice := map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}} + events, errCh := streamChatCompletionEvents(context.Background(), "gpt-5", nil, nil, tools, choice) + var chunks []map[string]any + for event := range events { + chunks = append(chunks, event) + } + if len(chunks) != 0 { + t.Fatalf("streamed chunks = %#v, want none", chunks) + } + var httpErr HTTPError + if err := <-errCh; !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "not an available tool") { + t.Fatalf("HTTPError = %#v, want missing forced tool error", httpErr) + } +} + +func TestStreamTextChatCompletionWithToolsRequiredToolErrorsBeforeUpstream(t *testing.T) { + events, errCh := (&Engine{}).StreamTextChatCompletionWithTools(context.Background(), nil, nil, "gpt-5", nil, "required") + var chunks []map[string]any + for event := range events { + chunks = append(chunks, event) + } + if len(chunks) != 0 { + t.Fatalf("streamed chunks = %#v, want none", chunks) + } + var httpErr HTTPError + if err := <-errCh; !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "tool_choice required") { + t.Fatalf("HTTPError = %#v, want status 400 with tool_choice required", httpErr) + } +} + +func TestStreamVisionChatCompletionWithToolsRequiredToolErrorsBeforeUpstream(t *testing.T) { + events, errCh := (&Engine{}).StreamVisionChatCompletionWithTools(context.Background(), nil, nil, "gpt-5", nil, nil, "required") + var chunks []map[string]any + for event := range events { + chunks = append(chunks, event) + } + if len(chunks) != 0 { + t.Fatalf("streamed chunks = %#v, want none", chunks) + } + var httpErr HTTPError + if err := <-errCh; !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "tool_choice required") { + t.Fatalf("HTTPError = %#v, want status 400 with tool_choice required", httpErr) + } +} + +func TestStreamTextChatCompletionWithToolsForcedToolErrorsBeforeUpstream(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "search"}}} + choice := map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}} + events, errCh := (&Engine{}).StreamTextChatCompletionWithTools(context.Background(), nil, nil, "gpt-5", tools, choice) + var chunks []map[string]any + for event := range events { + chunks = append(chunks, event) + } + if len(chunks) != 0 { + t.Fatalf("streamed chunks = %#v, want none", chunks) + } + var httpErr HTTPError + if err := <-errCh; !errors.As(err, &httpErr) { + t.Fatalf("err = %T %v, want HTTPError", err, err) + } + if httpErr.Status != 400 || !strings.Contains(httpErr.Message, "not an available tool") { + t.Fatalf("HTTPError = %#v, want missing forced tool error", httpErr) + } +} + +func TestStreamChatCompletionEventsReturnsPlainTextWhenNoCall(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}} + deltas := make(chan string, 2) + errs := make(chan error, 1) + deltas <- "hello " + deltas <- "world" + close(deltas) + errs <- nil + close(errs) + + events, errCh := streamChatCompletionEvents(context.Background(), "gpt-5", deltas, errs, tools, nil) + text := "" + finishReason := any(nil) + for event := range events { + choice := event["choices"].([]map[string]any)[0] + delta := choice["delta"].(map[string]any) + if content, ok := delta["content"].(string); ok { + text += content + } + if choice["finish_reason"] != nil { + finishReason = choice["finish_reason"] + } + } + if err := <-errCh; err != nil { + t.Fatalf("streamChatCompletionEvents() error = %v", err) + } + if text != "hello world" { + t.Fatalf("streamed text = %q, want hello world", text) + } + if finishReason != "stop" { + t.Fatalf("finish_reason = %#v, want stop", finishReason) + } +} + +func TestStreamChatCompletionEventsFlushesHeldMarkerWhenNoToolCall(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}} + deltas := make(chan string, 2) + errs := make(chan error, 1) + deltas <- "\nhello " + deltas <- "read_file{"path":"a.go"}` + close(deltas) + errs <- nil + close(errs) + + events, errCh := streamChatCompletionEvents(context.Background(), "gpt-5", deltas, errs, tools, nil) + text := "" + finishReason := any(nil) + var toolCalls []map[string]any + for event := range events { + choice := event["choices"].([]map[string]any)[0] + delta := choice["delta"].(map[string]any) + if content, ok := delta["content"].(string); ok { + text += content + } + if rawToolCalls, ok := delta["tool_calls"].([]map[string]any); ok { + toolCalls = rawToolCalls + } + if choice["finish_reason"] != nil { + finishReason = choice["finish_reason"] + } + } + if err := <-errCh; err != nil { + t.Fatalf("streamChatCompletionEvents() error = %v", err) + } + if text != "visible " { + t.Fatalf("streamed text = %q, want visible ", text) + } + if strings.Contains(text, "<") || strings.Contains(text, "tool_calls") { + t.Fatalf("streamed text leaked tool markup: %q", text) + } + if finishReason != "tool_calls" { + t.Fatalf("finish_reason = %#v, want tool_calls", finishReason) + } + if len(toolCalls) != 1 { + t.Fatalf("tool_calls = %#v, want one entry", toolCalls) + } +} + +func TestStreamChatCompletionEventsDoesNotLeakSplitSingularToolMarkup(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}} + deltas := make(chan string, 3) + errs := make(chan error, 1) + deltas <- "visible " + deltas <- "<" + deltas <- `tool_call>read_file{"path":"a.go"}` + close(deltas) + errs <- nil + close(errs) + + events, errCh := streamChatCompletionEvents(context.Background(), "gpt-5", deltas, errs, tools, nil) + text := "" + finishReason := any(nil) + var toolCalls []map[string]any + for event := range events { + choice := event["choices"].([]map[string]any)[0] + delta := choice["delta"].(map[string]any) + if content, ok := delta["content"].(string); ok { + text += content + } + if rawToolCalls, ok := delta["tool_calls"].([]map[string]any); ok { + toolCalls = rawToolCalls + } + if choice["finish_reason"] != nil { + finishReason = choice["finish_reason"] + } + } + if err := <-errCh; err != nil { + t.Fatalf("streamChatCompletionEvents() error = %v", err) + } + if text != "visible " { + t.Fatalf("streamed text = %q, want visible ", text) + } + if strings.Contains(text, "<") || strings.Contains(text, "tool_call") { + t.Fatalf("streamed text leaked tool markup: %q", text) + } + if finishReason != "tool_calls" { + t.Fatalf("finish_reason = %#v, want tool_calls", finishReason) + } + if len(toolCalls) != 1 { + t.Fatalf("tool_calls = %#v, want one entry", toolCalls) + } +} + +func TestChatPartsInjectToolPromptForTextAndVision(t *testing.T) { + tools := []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}} + textBody := map[string]any{ + "model": "gpt-5", + "messages": []any{map[string]any{"role": "user", "content": "read"}}, + "tools": tools, + } + _, textMessages, err := TextChatParts(textBody) + if err != nil { + t.Fatalf("TextChatParts() error = %v", err) + } + if len(textMessages) == 0 || textMessages[0]["role"] != "system" || !strings.Contains(textMessages[0]["content"].(string), "Tool: read_file") { + t.Fatalf("text messages missing tool prompt: %#v", textMessages) + } + + imageData := base64.StdEncoding.EncodeToString([]byte("png-bytes")) + visionBody := map[string]any{ + "model": "gpt-5", + "messages": []any{map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "text", "text": "read this image"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + imageData}}, + }}}, + "tools": tools, + } + _, visionMessages, images, err := VisionChatParts(visionBody) + if err != nil { + t.Fatalf("VisionChatParts() error = %v", err) + } + if len(visionMessages) == 0 || visionMessages[0]["role"] != "system" || !strings.Contains(visionMessages[0]["content"].(string), "Tool: read_file") { + t.Fatalf("vision messages missing tool prompt: %#v", visionMessages) + } + if len(images) != 1 || string(images[0].Data) != "png-bytes" { + t.Fatalf("VisionChatParts() images = %#v", images) + } +} + func TestListModelsUsesInjectedLister(t *testing.T) { called := false engine := &Engine{ @@ -274,6 +793,39 @@ func TestResponseImageGenerationRequestMapsTextModelToOfficialImageFlow(t *testi } } +func TestResponseImageGenerationRouteStillWinsWhenOtherToolsPresent(t *testing.T) { + body := map[string]any{ + "model": "gpt-5.5", + "input": []any{map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "input_text", "text": "生成一张横版产品图"}, + }}}, + "tools": []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "lookup_product", + "parameters": map[string]any{"type": "object"}, + }, + }, + map[string]any{"type": "image_generation", "model": "gpt-image-2", "size": "16:9"}, + }, + } + + if !HasResponseImageGenerationTool(body) { + t.Fatal("responses body with image_generation plus function tools should use the image generation route") + } + request, prompt, err := ResponseImageGenerationRequest(body, "linuxdo:1", nil) + if err != nil { + t.Fatalf("ResponseImageGenerationRequest() error = %v", err) + } + if prompt != "生成一张横版产品图" { + t.Fatalf("prompt = %q, want 生成一张横版产品图", prompt) + } + if request.Model != "gpt-image-2" || !request.UsesResponsesImageRoute() { + t.Fatalf("request route/model = %q responses=%v, want gpt-image-2 responses image route", request.Model, request.UsesResponsesImageRoute()) + } +} + func TestResponseImageGenerationRequestKeepsJPEGOutputCompression(t *testing.T) { body := map[string]any{ "model": "gpt-5.5", @@ -502,6 +1054,257 @@ func TestToolCallParsing(t *testing.T) { if stripped := StripToolMarkup(text); stripped != "先处理" { t.Fatalf("StripToolMarkup() = %q", stripped) } + + fenced := "```xml\nread_filehidden\n```" + if calls := ParseToolCalls(fenced); len(calls) != 0 { + t.Fatalf("ParseToolCalls() parsed fenced XML = %#v", calls) + } + + repeated := `read_filea.gob.go` + calls = ParseToolCalls(repeated) + if len(calls) != 1 { + t.Fatalf("ParseToolCalls() repeated fields = %#v", calls) + } + paths, ok := calls[0].Input["path"].([]any) + if !ok || len(paths) != 2 || paths[0] != "a.go" || paths[1] != "b.go" { + t.Fatalf("repeated path input = %#v", calls[0].Input["path"]) + } +} + +func TestStreamAnthropicEventsDoesNotDuplicateStreamedTextBeforeToolUse(t *testing.T) { + const visible = "先处理" + const toolXML = `read_fileinternal/app.go` + + oldStreamTextChatCompletion := streamTextChatCompletionForAnthropic + streamTextChatCompletionForAnthropic = func(ctx context.Context, e *Engine, request MessageRequest) (<-chan map[string]any, <-chan error) { + chunks := make(chan map[string]any, 3) + errs := make(chan error, 1) + chunks <- CompletionChunk(request.Model, map[string]any{"role": "assistant", "content": visible + "\n"}, nil, "chatcmpl_test", 1) + chunks <- CompletionChunk(request.Model, map[string]any{"content": toolXML}, nil, "chatcmpl_test", 1) + chunks <- CompletionChunk(request.Model, map[string]any{}, "stop", "chatcmpl_test", 1) + close(chunks) + errs <- nil + close(errs) + return chunks, errs + } + defer func() { streamTextChatCompletionForAnthropic = oldStreamTextChatCompletion }() + engine := &Engine{} + request := MessageRequest{ + Model: "auto", + Tools: []any{map[string]any{ + "name": "read_file", + "description": "read a file", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }}, + } + + events, errCh := engine.StreamAnthropicEvents(context.Background(), request) + textStarts := 0 + toolStarts := 0 + textDelta := "" + toolIndex := -1 + for event := range events { + if event["type"] == "content_block_start" { + block := event["content_block"].(map[string]any) + switch block["type"] { + case "text": + textStarts++ + case "tool_use": + toolStarts++ + toolIndex = event["index"].(int) + } + } + if event["type"] == "content_block_delta" && event["index"] == 0 { + delta := event["delta"].(map[string]any) + if delta["type"] == "text_delta" { + textDelta += delta["text"].(string) + } + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamAnthropicEvents() error = %v", err) + } + if textStarts != 1 || toolStarts != 1 { + t.Fatalf("content block starts = text:%d tool_use:%d, want 1/1", textStarts, toolStarts) + } + if strings.TrimSpace(textDelta) != visible { + t.Fatalf("streamed text delta = %q, want %q", textDelta, visible) + } + if toolIndex != 1 { + t.Fatalf("tool_use index = %d, want 1", toolIndex) + } +} + +func TestStreamAnthropicEventsDoesNotLeakSplitToolMarkup(t *testing.T) { + const toolXMLTail = `tool_calls>read_file{"path":"a.go"}` + + oldStreamTextChatCompletion := streamTextChatCompletionForAnthropic + streamTextChatCompletionForAnthropic = func(ctx context.Context, e *Engine, request MessageRequest) (<-chan map[string]any, <-chan error) { + chunks := make(chan map[string]any, 3) + errs := make(chan error, 1) + chunks <- CompletionChunk(request.Model, map[string]any{"role": "assistant", "content": "visible <"}, nil, "chatcmpl_test", 1) + chunks <- CompletionChunk(request.Model, map[string]any{"content": toolXMLTail}, nil, "chatcmpl_test", 1) + chunks <- CompletionChunk(request.Model, map[string]any{}, "stop", "chatcmpl_test", 1) + close(chunks) + errs <- nil + close(errs) + return chunks, errs + } + defer func() { streamTextChatCompletionForAnthropic = oldStreamTextChatCompletion }() + + events, errCh := (&Engine{}).StreamAnthropicEvents(context.Background(), MessageRequest{ + Model: "auto", + Tools: []any{map[string]any{"name": "read_file", "description": "read a file"}}, + }) + text := "" + toolStarts := 0 + for event := range events { + if event["type"] == "content_block_start" { + block := event["content_block"].(map[string]any) + if block["type"] == "tool_use" { + toolStarts++ + } + } + if event["type"] == "content_block_delta" { + delta := event["delta"].(map[string]any) + if delta["type"] == "text_delta" { + text += delta["text"].(string) + } + } + } + if err := <-errCh; err != nil { + t.Fatalf("StreamAnthropicEvents() error = %v", err) + } + if text != "visible " { + t.Fatalf("streamed text = %q, want visible ", text) + } + if strings.Contains(text, "<") || strings.Contains(text, "tool_calls") { + t.Fatalf("streamed text leaked tool markup: %q", text) + } + if toolStarts != 1 { + t.Fatalf("tool_use starts = %d, want 1", toolStarts) + } +} + +func TestStreamAnthropicEventsFlushesHeldMarkerWhenNoToolCall(t *testing.T) { + oldStreamTextChatCompletion := streamTextChatCompletionForAnthropic + streamTextChatCompletionForAnthropic = func(ctx context.Context, e *Engine, request MessageRequest) (<-chan map[string]any, <-chan error) { + chunks := make(chan map[string]any, 2) + errs := make(chan error, 1) + chunks <- CompletionChunk(request.Model, map[string]any{"role": "assistant", "content": "\nhello 0 { emitted = true - out <- ImageOutput{Kind: "result", Model: request.Model, Index: index, Total: total, Created: created, Data: data} + out <- ImageOutput{Kind: "result", Model: request.Model, Index: index, Total: total, Created: created, ConversationID: event.ConversationID, MessageID: event.MessageID, Data: data, ChargeHandled: chargeHandled} } } if err := <-upstreamErr; err != nil { @@ -711,7 +882,7 @@ func (e *Engine) StreamResponsesImageOutputs(ctx context.Context, client *backen return } if !emitted { - errCh <- fmt.Errorf("image generation failed") + errCh <- fmt.Errorf("upstream image stream completed without image output") return } errCh <- nil @@ -781,15 +952,50 @@ func isFinalImageTextEvent(event backend.ResponsesImageEvent) bool { if strings.TrimSpace(event.Text) == "" || event.Result != "" { return false } + if event.Type == "image_text_response" { + return true + } if event.Blocked { return true } if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { return true } + if responsesImageEventHasResultPointers(event) || isResponsesImageGenerationUseCase(event.TurnUseCase) { + return false + } return event.ToolInvoked != nil && !*event.ToolInvoked } +func responsesImageEventHasResultPointers(event backend.ResponsesImageEvent) bool { + return len(filterResponsesImageIDs(event.FileIDs)) > 0 || len(filterResponsesImageIDs(event.SedimentIDs)) > 0 +} + +func filterResponsesImageIDs(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || value == "file_upload" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func isResponsesImageGenerationUseCase(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.ReplaceAll(normalized, "_", " ") + normalized = strings.ReplaceAll(normalized, "-", " ") + normalized = strings.Join(strings.Fields(normalized), " ") + return normalized == "image gen" || normalized == "image generation" +} + func (e *Engine) CollectImageOutputs(outputs <-chan ImageOutput, errCh <-chan error) (map[string]any, error) { return e.CollectImageOutputsWithProgress(outputs, errCh, nil) } @@ -827,7 +1033,10 @@ func (e *Engine) CollectImageOutputsWithProgress(outputs <-chan ImageOutput, err } result := map[string]any{"created": created, "data": data} if len(data) == 0 { - if text := firstNonEmpty(message, strings.TrimSpace(strings.Join(progress, ""))); text != "" { + if text := strings.TrimSpace(message); text != "" { + result["message"] = text + result["output_type"] = "text" + } else if text := strings.TrimSpace(strings.Join(progress, "")); text != "" { result["message"] = text } } @@ -902,6 +1111,15 @@ func (e *Engine) FormatImageResult(items []map[string]any, prompt, responseForma } func (e *Engine) FormatImageResultWithOptions(items []map[string]any, prompt, responseFormat, baseURL, ownerID, ownerName string, created int64, message string, options ImageOutputOptions) map[string]any { + result, _ := e.formatImageResultWithOptions(items, prompt, responseFormat, baseURL, ownerID, ownerName, created, message, options, nil) + return result +} + +func (e *Engine) FormatImageResultWithCharge(items []map[string]any, prompt, responseFormat, baseURL, ownerID, ownerName string, created int64, message string, options ImageOutputOptions, charge func() error) (map[string]any, error) { + return e.formatImageResultWithOptions(items, prompt, responseFormat, baseURL, ownerID, ownerName, created, message, options, charge) +} + +func (e *Engine) formatImageResultWithOptions(items []map[string]any, prompt, responseFormat, baseURL, ownerID, ownerName string, created int64, message string, options ImageOutputOptions, charge func() error) (map[string]any, error) { defaultFormat := NormalizeImageOutputFormat(options.Format) hasRequestedFormat := strings.TrimSpace(options.Format) != "" var data []map[string]any @@ -940,6 +1158,18 @@ func (e *Engine) FormatImageResultWithOptions(items []map[string]any, prompt, re continue } } + if charge != nil { + if err := charge(); err != nil { + if created == 0 { + created = time.Now().Unix() + } + result := map[string]any{"created": created, "data": data} + if message != "" && len(data) == 0 { + result["message"] = message + } + return result, err + } + } outputFormat := NormalizeImageOutputFormat(itemOptions.Format) urlValue := e.SaveImageBytesForOwnerWithFormat(imageBytes, baseURL, ownerID, ownerName, outputFormat) responseItem := map[string]any{"url": urlValue, "revised_prompt": revised, "output_format": outputFormat} @@ -955,7 +1185,7 @@ func (e *Engine) FormatImageResultWithOptions(items []map[string]any, prompt, re if message != "" && len(data) == 0 { result["message"] = message } - return result + return result, nil } func (e *Engine) SaveImageBytes(imageData []byte, baseURL string) string { @@ -968,7 +1198,6 @@ func (e *Engine) SaveImageBytesForOwner(imageData []byte, baseURL, ownerID, owne func (e *Engine) SaveImageBytesForOwnerWithFormat(imageData []byte, baseURL, ownerID, ownerName, outputFormat string) string { outputFormat = NormalizeImageOutputFormat(outputFormat) - e.Config.CleanupOldImages() sum := md5.Sum(imageData) filename := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), hex.EncodeToString(sum[:]), imageFileExtension(outputFormat)) relativeDir := filepath.Join(time.Now().Format("2006"), time.Now().Format("01"), time.Now().Format("02")) @@ -1123,6 +1352,27 @@ func NormalizeMessages(messages any, system any) []map[string]any { return normalized } +func TokenCountMessages(messages any, system any) []map[string]any { + var out []map[string]any + if text := MessageText(system); text != "" { + out = append(out, map[string]any{"role": "system", "content": text}) + } + if list, ok := messages.([]map[string]any); ok { + for _, message := range list { + out = append(out, map[string]any{"role": firstNonEmpty(util.Clean(message["role"]), "user"), "content": message["content"]}) + } + return out + } + if list, ok := messages.([]any); ok { + for _, raw := range list { + if message, ok := raw.(map[string]any); ok { + out = append(out, map[string]any{"role": firstNonEmpty(util.Clean(message["role"]), "user"), "content": message["content"]}) + } + } + } + return out +} + func AssistantHistoryText(messages []map[string]any) string { var parts []string for _, item := range messages { @@ -1220,11 +1470,24 @@ func buildResponsesImagePrompt(prompt, size, model string) string { return BuildImagePrompt(prompt, size, "") } +const ( + imageLowDetailTokens = 85 + imageTileTokens = 170 + imageUnknownDetailTokens = 765 + imageTokenTileSize = 512 + imageTokenMaxDimension = 2048 + imageTokenShortDimension = 768 +) + func CountMessageTokens(messages []map[string]any, model string) int { total := 3 for _, message := range messages { total += 3 for key, value := range message { + if key == "content" { + total += CountContentTokens(value, model) + continue + } if text, ok := value.(string); ok { total += CountTextTokens(text, model) if key == "name" { @@ -1236,6 +1499,116 @@ func CountMessageTokens(messages []map[string]any, model string) int { return total } +func CountContentTokens(content any, model string) int { + switch v := content.(type) { + case string: + return CountTextTokens(v, model) + case []any: + total := 0 + for _, part := range v { + total += CountContentPartTokens(part, model) + } + return total + case []map[string]any: + total := 0 + for _, part := range v { + total += CountContentPartTokens(part, model) + } + return total + default: + return 0 + } +} + +func CountContentPartTokens(part any, model string) int { + m := util.StringMap(part) + if len(m) == 0 { + return 0 + } + switch strings.ToLower(strings.TrimSpace(util.Clean(m["type"]))) { + case "text", "input_text": + return CountTextTokens(util.Clean(m["text"]), model) + case "image_url", "input_image": + return CountImagePartTokens(m) + default: + return 0 + } +} + +func CountImagePartTokens(part any) int { + urlValue, detail := imagePartURLAndDetail(part) + if strings.EqualFold(strings.TrimSpace(detail), "low") { + return imageLowDetailTokens + } + if width, height, ok := imageDimensionsFromDataURL(urlValue); ok { + return estimateImageTokensFromDimensions(width, height) + } + return imageUnknownDetailTokens +} + +func imagePartURLAndDetail(part any) (string, string) { + m := util.StringMap(part) + if len(m) == 0 { + return "", "" + } + for _, key := range []string{"image_url", "input_image"} { + value := m[key] + if nested := util.StringMap(value); len(nested) > 0 { + return firstNonEmpty(util.Clean(nested["url"]), util.Clean(nested["image_url"])), firstNonEmpty(util.Clean(nested["detail"]), util.Clean(m["detail"])) + } + if text := util.Clean(value); text != "" { + return text, util.Clean(m["detail"]) + } + } + return firstNonEmpty(util.Clean(m["url"]), util.Clean(m["image_url"])), util.Clean(m["detail"]) +} + +func imageDimensionsFromDataURL(value string) (int, int, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(strings.ToLower(value), "data:image/") { + return 0, 0, false + } + header, dataPart, ok := strings.Cut(value, ",") + if !ok || !strings.Contains(strings.ToLower(header), ";base64") { + return 0, 0, false + } + data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataPart)) + if err != nil { + return 0, 0, false + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || config.Width <= 0 || config.Height <= 0 { + return 0, 0, false + } + return config.Width, config.Height, true +} + +func estimateImageTokensFromDimensions(width, height int) int { + if width <= 0 || height <= 0 { + return imageUnknownDetailTokens + } + maxDimension := max(width, height) + if maxDimension > imageTokenMaxDimension { + width = ceilDiv(width*imageTokenMaxDimension, maxDimension) + height = ceilDiv(height*imageTokenMaxDimension, maxDimension) + } + shortDimension := min(width, height) + if shortDimension > imageTokenShortDimension { + width = ceilDiv(width*imageTokenShortDimension, shortDimension) + height = ceilDiv(height*imageTokenShortDimension, shortDimension) + } + tilesWide := ceilDiv(width, imageTokenTileSize) + tilesHigh := ceilDiv(height, imageTokenTileSize) + return imageLowDetailTokens + imageTileTokens*tilesWide*tilesHigh +} + +func ceilDiv(value, divisor int) int { + if divisor <= 0 { + return 0 + } + return (value + divisor - 1) / divisor +} + func CountTextTokens(text, model string) int { runes := []rune(text) if len(runes) == 0 { diff --git a/internal/protocol/conversation_test.go b/internal/protocol/conversation_test.go index eeb37d4c3..4751f4ea7 100644 --- a/internal/protocol/conversation_test.go +++ b/internal/protocol/conversation_test.go @@ -5,10 +5,13 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "image" "image/color" "image/png" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -17,12 +20,17 @@ import ( "time" "chatgpt2api/internal/backend" + "chatgpt2api/internal/service" ) type testProtocolImageConfig struct { root string } +type testProtocolProxyConfig struct{} + +func (testProtocolProxyConfig) Proxy() string { return "" } + func (c testProtocolImageConfig) ImagesDir() string { path := filepath.Join(c.root, "images") _ = os.MkdirAll(path, 0o755) @@ -39,8 +47,221 @@ func (c testProtocolImageConfig) BaseURL() string { return "https://example.test" } -func (c testProtocolImageConfig) CleanupOldImages() int { - return 0 +func testPNGDataURL(t *testing.T, width, height int) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, width, height)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("png.Encode() error = %v", err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func TestCountMessageTokensCountsTextContentParts(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "look"}, + map[string]any{"type": "text", "text": "again"}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + want := 3 + 3 + CountTextTokens("user", "gpt-5") + CountTextTokens("look", "gpt-5") + CountTextTokens("again", "gpt-5") + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } +} + +func TestCountMessageTokensCountsImageURLLowDetail(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "look"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/image.png", "detail": "low"}}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + base := 3 + 3 + CountTextTokens("user", "gpt-5") + CountTextTokens("look", "gpt-5") + want := base + 85 + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } +} + +func TestCountMessageTokensImageURLTopLevelDetailFallback(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "image_url", "detail": "low", "image_url": map[string]any{"url": "https://example.test/image.png"}}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + base := 3 + 3 + CountTextTokens("user", "gpt-5") + want := base + 85 + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } +} + +func TestCountMessageTokensCountsImageURLDataURLDimensions(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "look"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": testPNGDataURL(t, 1, 1), "detail": "high"}}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + base := 3 + 3 + CountTextTokens("user", "gpt-5") + CountTextTokens("look", "gpt-5") + want := base + 255 + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } +} + +func TestCompletionResponseIncludesImagePromptTokens(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "look"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": testPNGDataURL(t, 1, 1), "detail": "high"}}, + }, + }} + + response := CompletionResponse("gpt-5", "ok", 123, messages) + usage := response["usage"].(map[string]any) + promptTokens := usage["prompt_tokens"].(int) + completionTokens := usage["completion_tokens"].(int) + totalTokens := usage["total_tokens"].(int) + + wantPrompt := CountMessageTokens(messages, "gpt-5") + wantCompletion := CountTextTokens("ok", "gpt-5") + if promptTokens != wantPrompt { + t.Fatalf("prompt_tokens = %d, want %d", promptTokens, wantPrompt) + } + if completionTokens != wantCompletion { + t.Fatalf("completion_tokens = %d, want %d", completionTokens, wantCompletion) + } + if totalTokens != wantPrompt+wantCompletion { + t.Fatalf("total_tokens = %d, want %d", totalTokens, wantPrompt+wantCompletion) + } +} + +func TestTokenCountMessagesPreservesContentPartsAndPrependsToolPrompt(t *testing.T) { + body := map[string]any{ + "messages": []any{map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "look"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/image.png", "detail": "low"}}, + }}}, + "tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}}, + } + + messages := TokenCountMessages(body["messages"], ChatToolPrompt(body)) + if len(messages) != 2 { + t.Fatalf("TokenCountMessages() len = %d, want 2: %#v", len(messages), messages) + } + if messages[0]["role"] != "system" || !strings.Contains(messages[0]["content"].(string), "Tool: read_file") { + t.Fatalf("system tool prompt not prepended: %#v", messages[0]) + } + if messages[1]["role"] != "user" { + t.Fatalf("default role = %#v, want user", messages[1]["role"]) + } + parts, ok := messages[1]["content"].([]any) + if !ok || len(parts) != 2 { + t.Fatalf("content parts were not preserved: %#v", messages[1]["content"]) + } + imagePart, ok := parts[1].(map[string]any) + if !ok || imagePart["type"] != "image_url" { + t.Fatalf("image_url part was not preserved: %#v", parts[1]) + } + + normalized := NormalizeMessages(body["messages"], ChatToolPrompt(body)) + if normalized[1]["content"] != "look" { + t.Fatalf("NormalizeMessages() content = %#v, want text-only look", normalized[1]["content"]) + } +} + +func TestCompletionResponseIncludesImagePromptTokensWithTokenCountMessages(t *testing.T) { + body := map[string]any{ + "model": "gpt-5", + "messages": []any{map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "text", "text": "look"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": testPNGDataURL(t, 1, 1), "detail": "high"}}, + }}}, + "tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}}, + } + rawMessages, err := ChatMessagesFromBody(body) + if err != nil { + t.Fatalf("ChatMessagesFromBody() error = %v", err) + } + usageMessages := TokenCountMessages(rawMessages, ChatToolPrompt(body)) + normalizedMessages := NormalizeMessages(rawMessages, ChatToolPrompt(body)) + + response, err := CompletionResponseWithTools("gpt-5", "ok", 123, usageMessages, body["tools"], body["tool_choice"]) + if err != nil { + t.Fatalf("CompletionResponseWithTools() error = %v", err) + } + usage := response["usage"].(map[string]any) + promptTokens := usage["prompt_tokens"].(int) + wantPrompt := CountMessageTokens(usageMessages, "gpt-5") + if promptTokens != wantPrompt { + t.Fatalf("prompt_tokens = %d, want %d", promptTokens, wantPrompt) + } + if promptTokens <= CountMessageTokens(normalizedMessages, "gpt-5") { + t.Fatalf("prompt_tokens = %d, want greater than normalized text-only count", promptTokens) + } +} + +func TestCountMessageTokensCountsHighDetailDataURLAfterShortSideScaling(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "image_url", "image_url": map[string]any{"url": testPNGDataURL(t, 2048, 2048), "detail": "high"}}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + base := 3 + 3 + CountTextTokens("user", "gpt-5") + want := base + 765 + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } +} + +func TestCountMessageTokensImageURLFallbackDoesNotFetchRemote(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://127.0.0.1:1/not-fetched.png"}}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + base := 3 + 3 + CountTextTokens("user", "gpt-5") + want := base + 765 + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } +} + +func TestCountMessageTokensIgnoresUnknownContentParts(t *testing.T) { + messages := []map[string]any{{ + "role": "user", + "content": []any{ + map[string]any{"type": "file", "file": map[string]any{"file_id": "file_123", "size": 1024}}, + }, + }} + + got := CountMessageTokens(messages, "gpt-5") + want := 3 + 3 + CountTextTokens("user", "gpt-5") + if got != want { + t.Fatalf("CountMessageTokens() = %d, want %d", got, want) + } } func TestFormatImageResultStoresOwnerName(t *testing.T) { @@ -326,11 +547,234 @@ func TestImageStreamErrorMessage(t *testing.T) { if got := imageStreamErrorMessage(flowControl); got != "upstream image stream interrupted by HTTP/2 flow control; retry the request or change proxy if it repeats" { t.Fatalf("flow control error = %q", got) } - if got := imageStreamErrorMessage(""); got != "image generation failed" { + if got := imageStreamErrorMessage(""); got != "upstream image request failed without error detail" { t.Fatalf("empty error = %q", got) } } +func TestHandleImageGenerationsReturnsUpstreamTextResponse(t *testing.T) { + engine := &Engine{ + ImageTokenProvider: func(context.Context) (string, error) { return "test-token", nil }, + ImageClientFactory: func(string) *backend.Client { return nil }, + } + engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request ConversationRequest, index, total int) (<-chan ImageOutput, <-chan error) { + out := make(chan ImageOutput, 1) + errCh := make(chan error, 1) + out <- ImageOutput{Kind: "message", Model: request.Model, Index: index, Total: total, Created: time.Now().Unix(), Text: "你好!我是 ChatGPT。", UpstreamEventType: "image_text_response"} + close(out) + errCh <- nil + close(errCh) + return out, errCh + } + + result, _, err := engine.HandleImageGenerations(context.Background(), map[string]any{ + "prompt": "你好,你是什么模型?", + "model": "gpt-image-2", + }) + if err == nil { + t.Fatal("HandleImageGenerations() error = nil, want text-response image error") + } + var imageErr *ImageGenerationError + if !errors.As(err, &imageErr) { + t.Fatalf("HandleImageGenerations() error = %T %v, want ImageGenerationError", err, err) + } + if imageErr.Code != "image_generation_text_response" || imageErr.Message != "你好!我是 ChatGPT。" { + t.Fatalf("image error = %#v", imageErr) + } + if result["output_type"] != "text" { + t.Fatalf("output_type = %#v, want text in %#v", result["output_type"], result) + } + if result["message"] != "你好!我是 ChatGPT。" { + t.Fatalf("message = %#v, want upstream text", result["message"]) + } +} + +func TestHandleImageGenerationsReturnsArbitraryUpstreamImageText(t *testing.T) { + const upstreamText = "上游返回的任何非排队文本都应该原样返回。" + engine := &Engine{ + ImageTokenProvider: func(context.Context) (string, error) { return "test-token", nil }, + ImageClientFactory: func(string) *backend.Client { return nil }, + } + engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request ConversationRequest, index, total int) (<-chan ImageOutput, <-chan error) { + out := make(chan ImageOutput, 1) + errCh := make(chan error, 1) + out <- ImageOutput{Kind: "message", Model: request.Model, Index: index, Total: total, Created: time.Now().Unix(), Text: upstreamText, UpstreamEventType: "image_text_response"} + close(out) + errCh <- nil + close(errCh) + return out, errCh + } + + result, _, err := engine.HandleImageGenerations(context.Background(), map[string]any{ + "prompt": "draw", + "model": "gpt-image-2", + }) + if err == nil { + t.Fatal("HandleImageGenerations() error = nil, want text-response image error") + } + if result["output_type"] != "text" || result["message"] != upstreamText { + t.Fatalf("result = %#v, want arbitrary upstream text response", result) + } +} + +func TestImageConversationFallbackReferenceUsedOnlyForNewUpstreamSession(t *testing.T) { + fallback := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("fallback")) + sessions := service.NewImageConversationSessionService(filepath.Join(t.TempDir(), "sessions.json")) + sessions.Bind(service.ImageConversationSession{ + OwnerID: "owner-1", + FrontendConversationID: "front-1", + AccessToken: "bound-token", + UpstreamConversationID: "conv-1", + UpstreamParentMessageID: "msg-1", + }) + engine := &Engine{ + ImageConversationSessions: sessions, + ImageTokenProvider: func(context.Context) (string, error) { return "bound-token", nil }, + ImageClientFactory: func(string) *backend.Client { return nil }, + } + var continuedRequest ConversationRequest + engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request ConversationRequest, index, total int) (<-chan ImageOutput, <-chan error) { + continuedRequest = request + out := make(chan ImageOutput, 1) + errCh := make(chan error, 1) + out <- ImageOutput{Kind: "result", Model: request.Model, Index: index, Total: total, Created: time.Now().Unix(), ConversationID: "conv-1", MessageID: "msg-2", Data: []map[string]any{{"b64_json": "image"}}} + close(out) + errCh <- nil + close(errCh) + return out, errCh + } + outputs, errCh := engine.StreamImageOutputsWithPool(context.Background(), ConversationRequest{Prompt: "continue", Model: "gpt-image-2", N: 1, OwnerID: "owner-1", FrontendConversationID: "front-1", Images: []string{"current"}, FallbackReferenceImage: fallback}) + if _, err := engine.CollectImageOutputs(outputs, errCh); err != nil { + t.Fatalf("CollectImageOutputs() error = %v", err) + } + if continuedRequest.UpstreamConversationID != "conv-1" || continuedRequest.UpstreamParentMessageID != "msg-1" { + t.Fatalf("continuation pointers = %q/%q", continuedRequest.UpstreamConversationID, continuedRequest.UpstreamParentMessageID) + } + if got := strings.Join(continuedRequest.Images, ","); got != "current" { + t.Fatalf("continued request images = %q, want current only", got) + } + + engine.ImageConversationSessions = service.NewImageConversationSessionService(filepath.Join(t.TempDir(), "sessions.json")) + engine.ImageTokenProvider = func(context.Context) (string, error) { return "new-token", nil } + var newRequest ConversationRequest + engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request ConversationRequest, index, total int) (<-chan ImageOutput, <-chan error) { + newRequest = request + out := make(chan ImageOutput, 1) + errCh := make(chan error, 1) + out <- ImageOutput{Kind: "result", Model: request.Model, Index: index, Total: total, Created: time.Now().Unix(), ConversationID: "conv-new", MessageID: "msg-new", Data: []map[string]any{{"b64_json": "image"}}} + close(out) + errCh <- nil + close(errCh) + return out, errCh + } + outputs, errCh = engine.StreamImageOutputsWithPool(context.Background(), ConversationRequest{Prompt: "new", Model: "gpt-image-2", N: 1, OwnerID: "owner-1", FrontendConversationID: "front-2", Images: []string{"current"}, FallbackReferenceImage: fallback}) + if _, err := engine.CollectImageOutputs(outputs, errCh); err != nil { + t.Fatalf("CollectImageOutputs() new session error = %v", err) + } + if newRequest.UpstreamConversationID != "" || newRequest.UpstreamParentMessageID != "" { + t.Fatalf("new request continuation pointers = %q/%q, want empty", newRequest.UpstreamConversationID, newRequest.UpstreamParentMessageID) + } + if len(newRequest.Images) != 2 || newRequest.Images[0] != "current" || newRequest.Images[1] != fallback { + t.Fatalf("new request images = %#v, want current plus fallback", newRequest.Images) + } +} + +func TestStreamResponsesImageOutputsCompletesWithUpstreamRefusalText(t *testing.T) { + const upstreamText = "非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。如果你认为此判断有误,请重试或修改提示语。" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/f/conversation/prepare": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/f/conversation": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"title_generation\",\"title\":\"正在处理图片\",\"conversation_id\":\"conv-refused\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-refused\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-refused": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{ + "assistant-text":{"message":{"author":{"role":"assistant"},"create_time":3,"content":{"content_type":"text","parts":["` + upstreamText + `"]},"status":"finished_successfully","recipient":"all","metadata":{"model_slug":"gpt-5-5"}}} + }}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + engine := &Engine{ + ImageTokenProvider: func(context.Context) (string, error) { return "test-token", nil }, + ImageClientFactory: func(token string) *backend.Client { + client := backend.NewClient(token, nil, service.NewProxyService(testProtocolProxyConfig{})) + client.BaseURL = server.URL + return client + }, + } + + outputs, imageErr := engine.StreamImageOutputsWithPool(context.Background(), ConversationRequest{ + Prompt: "edit", + Model: "gpt-image-2", + N: 1, + }) + result, err := engine.CollectImageOutputs(outputs, imageErr) + if err != nil { + t.Fatalf("CollectImageOutputs() err = %v", err) + } + if result["output_type"] != "text" || result["message"] != upstreamText { + t.Fatalf("result = %#v, want upstream refusal text as text output", result) + } +} + +func TestIsFinalImageTextEventIgnoresImageGenMetadataWithResultIDs(t *testing.T) { + toolFalse := false + event := backend.ResponsesImageEvent{ + Type: "server_ste_metadata", + Text: "Here is the generated image.", + ToolInvoked: &toolFalse, + TurnUseCase: "image gen", + SedimentIDs: []string{"file_image"}, + ConversationID: "conv-image", + } + + if isFinalImageTextEvent(event) { + t.Fatalf("isFinalImageTextEvent(%#v) = true, want false for image generation metadata", event) + } +} + +func TestIsFinalImageTextEventWaitsForBackendTextMarkerOnImageGenRefusal(t *testing.T) { + event := backend.ResponsesImageEvent{ + Type: "message_stream_complete", + Text: "非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。如果你认为此判断有误,请重试或修改提示语。", + TurnUseCase: "image gen", + } + + if isFinalImageTextEvent(event) { + t.Fatalf("isFinalImageTextEvent(%#v) = true, want false before backend marks final text", event) + } + + event.Type = "image_text_response" + if !isFinalImageTextEvent(event) { + t.Fatalf("isFinalImageTextEvent(%#v) = false, want true after backend marks final text", event) + } +} + +func TestIsFinalImageTextEventKeepsQueuedImageNoticePending(t *testing.T) { + event := backend.ResponsesImageEvent{ + Type: "message_stream_complete", + Text: "正在处理图片,目前有很多人在创建图片,因此可能需要一点时间。图片准备好后我们会通知你。", + TurnUseCase: "image gen", + } + + if isFinalImageTextEvent(event) { + t.Fatalf("isFinalImageTextEvent(%#v) = true, want false for queued image notice", event) + } +} + func TestIsTransientImageStreamErrorMessage(t *testing.T) { transient := []string{ "responses SSE read error: stream error: stream ID 1; INTERNAL_ERROR; received from peer", @@ -339,6 +783,8 @@ func TestIsTransientImageStreamErrorMessage(t *testing.T) { "unexpected EOF", "connection reset by peer", "stream closed", + "bootstrap failed: upstream connection failed before TLS handshake completed; check proxy reachability to chatgpt.com or change proxy", + `bootstrap failed: Get "https://chatgpt.com/": surf: HTTP/2 request failed: uTLS.HandshakeContext() error: EOF; HTTP/1.1 fallback failed: uTLS.HandshakeContext() error: EOF`, } for _, input := range transient { if !isTransientImageStreamErrorMessage(input) { diff --git a/internal/service/account.go b/internal/service/account.go index a080aef2b..228188cd9 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -16,6 +16,20 @@ import ( "chatgpt2api/internal/util" ) +// Account map fields stored by the storage layer. +// +// access_token - ChatGPT access token (JWT), required, unique account identifier +// session_token - Session token used to refresh access_token automatically +// type - Account type: Free / Plus / ProLite / Pro / Team +// status - Account status: normal / invalid / limited / disabled / refresh pending / refreshing +// quota - Remaining quota count +// image_quota_unknown - Whether the image quota is unknown +// email - Linked email address +// user_id - User ID +// chatgpt_account_id - ChatGPT account ID +// limits_progress - Usage limit progress +// default_model_slug - Default model slug +// restore_at - Quota restore time type AccountConfig interface { AutoRemoveInvalidAccounts() bool AutoRemoveRateLimitedAccounts() bool @@ -32,6 +46,9 @@ type AccountService struct { imageReservations map[string]int remoteBaseURL string browserHTTPClient func(profile string, timeout time.Duration) *http.Client + textRequestCount map[string]int + textCooldownUntil time.Time + refresher *SessionRefresher } const ( @@ -55,7 +72,16 @@ func NewAccountService(backend storage.Backend, config AccountConfig, proxy *Pro imageReservations: map[string]int{}, remoteBaseURL: "https://chatgpt.com", browserHTTPClient: browserHTTPClient, + textRequestCount: map[string]int{}, } + // Initialize SessionRefresher with the uTLS client for /api/auth/session. + s.refresher = NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + client := s.browserHTTPClient(defaultRemoteProfile, refreshTimeout) + if client == nil { + client = &http.Client{Timeout: refreshTimeout} + } + return client.Do(req) + }) s.items = s.loadAccounts() return s } @@ -128,6 +154,24 @@ func (s *AccountService) ListLimitedTokens() []string { return out } +func (s *AccountService) listRefreshableLimitedTokens(now time.Time) []string { + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for _, item := range s.items { + if item["status"] != "限流" { + continue + } + if restoreAt, ok := parseAccountRestoreAt(item["restore_at"]); ok && restoreAt.After(now) { + continue + } + if token := util.Clean(item["access_token"]); token != "" { + out = append(out, token) + } + } + return out +} + func (s *AccountService) AddAccounts(tokens []string) map[string]any { cleaned := cleanTokens(tokens) if len(cleaned) == 0 { @@ -181,6 +225,116 @@ func (s *AccountService) AddAccounts(tokens []string) map[string]any { return map[string]any{"added": added, "skipped": skipped, "items": items} } +func (s *AccountService) AddAccountFromSession(sessionJSON string) (map[string]any, error) { + var session struct { + AccessToken string `json:"accessToken"` + Expires any `json:"expires"` + SessionToken string `json:"sessionToken"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` + } + if err := json.Unmarshal([]byte(sessionJSON), &session); err != nil { + return nil, fmt.Errorf("invalid session JSON: %w", err) + } + accessToken := util.Clean(session.AccessToken) + if accessToken == "" { + return nil, fmt.Errorf("session JSON missing accessToken") + } + sessionToken := util.Clean(session.SessionToken) + if sessionToken == "" { + return nil, fmt.Errorf("session JSON missing sessionToken") + } + + validated, err := s.refresher.RefreshSession(context.Background(), accessToken, sessionToken) + if err != nil { + return nil, fmt.Errorf("session token validation failed: %w", err) + } + accessToken = validated.AccessToken + if validated.SessionToken != "" { + sessionToken = validated.SessionToken + } + sessionExpires := any(session.Expires) + if validated.Expires != "" { + sessionExpires = validated.Expires + } + + userID := util.Clean(validated.User.ID) + email := util.Clean(validated.User.Email) + updates := map[string]any{ + "session_token": sessionToken, + "session_expires": sessionExpires, + } + if userID != "" { + updates["user_id"] = userID + } + if email != "" { + updates["email"] = email + } + if name := util.Clean(validated.User.Name); name != "" { + updates["name"] = name + } + + matchedToken := s.findSessionImportAccountToken(accessToken, userID, email) + result := map[string]any{"added": 0, "skipped": 0, "updated": 0, "items": s.ListAccounts()} + if matchedToken != "" { + if !s.UpdateAccountFromSessionImport(matchedToken, accessToken, updates, true) { + return nil, fmt.Errorf("session account update failed") + } + result["updated"] = 1 + } else { + result = s.AddAccounts([]string{accessToken}) + } + if item := s.UpdateAccount(accessToken, updates); item != nil { + publicItems := publicAccounts([]map[string]any{item}) + if len(publicItems) > 0 { + result["item"] = publicItems[0] + } + result["items"] = s.ListAccounts() + } + result["tokens"] = []string{accessToken} + return result, nil +} + +func isRecoverableSessionImportStatus(status string) bool { + switch status { + case "异常", "过期待刷新", "刷新中": + return true + default: + return false + } +} + +func (s *AccountService) findSessionImportAccountToken(accessToken, userID, email string) string { + accessToken = util.Clean(accessToken) + userID = util.Clean(userID) + email = strings.ToLower(util.Clean(email)) + + s.mu.Lock() + defer s.mu.Unlock() + + if accessToken != "" && s.findIndexLocked(accessToken) >= 0 { + return accessToken + } + if userID != "" { + for _, item := range s.items { + if util.Clean(item["user_id"]) == userID { + return util.Clean(item["access_token"]) + } + } + } + if email != "" { + for _, item := range s.items { + if strings.ToLower(util.Clean(item["email"])) == email { + return util.Clean(item["access_token"]) + } + } + } + return "" +} + func (s *AccountService) DeleteAccounts(tokens []string) map[string]any { targets := map[string]struct{}{} for _, token := range cleanTokens(tokens) { @@ -197,6 +351,7 @@ func (s *AccountService) DeleteAccounts(tokens []string) map[string]any { if _, ok := targets[token]; ok { removed++ delete(s.imageReservations, token) + delete(s.textRequestCount, token) continue } next = append(next, item) @@ -263,6 +418,58 @@ func (s *AccountService) UpdateAccount(accessToken string, updates map[string]an return util.CopyMap(account) } +func (s *AccountService) UpdateAccountFromSessionImport(oldAccessToken, newAccessToken string, updates map[string]any, recoverStatus bool) bool { + oldAccessToken = util.Clean(oldAccessToken) + newAccessToken = util.Clean(newAccessToken) + if oldAccessToken == "" || newAccessToken == "" { + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + + idx := s.findIndexLocked(oldAccessToken) + if idx < 0 { + return false + } + if oldAccessToken != newAccessToken { + if duplicateIdx := s.findIndexLocked(newAccessToken); duplicateIdx >= 0 && duplicateIdx != idx { + s.items = append(s.items[:duplicateIdx], s.items[duplicateIdx+1:]...) + if duplicateIdx < idx { + idx-- + } + } + } + + accountUpdates := mergeMaps(updates, map[string]any{"access_token": newAccessToken}) + if recoverStatus && isRecoverableSessionImportStatus(util.Clean(s.items[idx]["status"])) { + accountUpdates["status"] = "正常" + } + account := normalizeAccount(mergeMaps(s.items[idx], accountUpdates)) + if account == nil { + return false + } + s.items[idx] = account + if oldAccessToken != newAccessToken { + if count, ok := s.imageReservations[oldAccessToken]; ok { + s.imageReservations[newAccessToken] = count + delete(s.imageReservations, oldAccessToken) + } + if count, ok := s.textRequestCount[oldAccessToken]; ok { + s.textRequestCount[newAccessToken] = count + delete(s.textRequestCount, oldAccessToken) + } + } + _ = s.saveLocked() + s.logs.Add("更新Session账号", map[string]any{ + "module": "accounts", + "operation_type": "更新", + "token": util.AnonymizeToken(newAccessToken), + "status": account["status"], + }) + return true +} + func (s *AccountService) GetAccount(accessToken string) map[string]any { accessToken = util.Clean(accessToken) if accessToken == "" { @@ -277,16 +484,163 @@ func (s *AccountService) GetAccount(accessToken string) map[string]any { return util.CopyMap(s.items[idx]) } +const MaxTokenSwitchAttempts = 5 + func (s *AccountService) GetTextAccessToken() string { s.mu.Lock() defer s.mu.Unlock() + + nonFree := s.filterNonFreeLocked() + if len(nonFree) > 0 { + return s.selectFromTextPoolLocked(nonFree, false) + } + + free := s.filterFreeLocked() + if len(free) > 0 { + return s.selectFromTextPoolLocked(free, true) + } + + return "" +} + +func (s *AccountService) GetTextAccessTokenWithRetry(exhaustedTokens map[string]struct{}) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + nonFree := s.filterNonFreeLocked() + free := s.filterFreeLocked() + + selectFrom := func(pool []map[string]any) string { + var bestToken string + bestCount := int(^uint(0) >> 1) + for _, item := range pool { + token := util.Clean(item["access_token"]) + if _, exhausted := exhaustedTokens[token]; exhausted { + continue + } + count := s.textRequestCount[token] + if count < bestCount { + bestCount = count + bestToken = token + } + } + if bestToken != "" { + s.textRequestCount[bestToken] = bestCount + 1 + } + return bestToken + } + + if token := selectFrom(nonFree); token != "" { + return token, true + } + if token := selectFrom(free); token != "" { + return token, true + } + return "", false +} + +func (s *AccountService) HandleTokenExpiredOnRequest(expiredToken string) (newToken string, shouldRetry bool) { + account := s.GetAccount(expiredToken) + if account == nil { + return "", false + } + + sessionToken := util.Clean(account["session_token"]) + if sessionToken == "" { + return "", false + } + if s.UpdateAccount(expiredToken, map[string]any{"status": "刷新中"}) == nil { + return "", false + } + s.refreshAccountViaSessionAsync(expiredToken, sessionToken) + return "", true +} + +func (s *AccountService) refreshAccountViaSessionAsync(accessToken, sessionToken string) { + if s.refresher.IsRefreshing(accessToken) { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout) + defer cancel() + + newAccessToken, newSessionToken, newExpires, err := s.refresher.RefreshToken(ctx, accessToken, sessionToken) + if err != nil { + s.UpdateAccount(accessToken, map[string]any{"status": "异常"}) + return + } + s.RefreshAccountViaSession(accessToken, newAccessToken, newSessionToken, newExpires) + }() +} + +func (s *AccountService) filterNonFreeLocked() []map[string]any { + var out []map[string]any for _, item := range s.items { status := util.Clean(item["status"]) - if status != "禁用" && status != "异常" { - return util.Clean(item["access_token"]) + if status == "禁用" || status == "异常" || status == "刷新中" || status == "过期待刷新" { + continue + } + if IsPaidImageAccount(item) { + out = append(out, item) } } - return "" + return out +} + +func (s *AccountService) filterFreeLocked() []map[string]any { + var out []map[string]any + for _, item := range s.items { + status := util.Clean(item["status"]) + if status == "禁用" || status == "异常" || status == "刷新中" || status == "过期待刷新" { + continue + } + if !IsPaidImageAccount(item) { + out = append(out, item) + } + } + return out +} + +func (s *AccountService) selectFromTextPoolLocked(pool []map[string]any, isFree bool) string { + const maxRequestsPerAccount = 10 + + var bestToken string + bestCount := int(^uint(0) >> 1) + allExhausted := true + for _, item := range pool { + token := util.Clean(item["access_token"]) + count := s.textRequestCount[token] + if count < bestCount { + bestCount = count + bestToken = token + } + if count < maxRequestsPerAccount { + allExhausted = false + } + } + + if allExhausted { + if isFree { + now := time.Now() + if now.After(s.textCooldownUntil) { + s.resetTextCountsLocked(pool) + s.textCooldownUntil = now.Add(5 * time.Hour) + bestCount = 0 + } + } else if len(pool) > 1 { + s.resetTextCountsLocked(pool) + bestCount = 0 + } + } + + s.textRequestCount[bestToken] = bestCount + 1 + return bestToken +} + +func (s *AccountService) resetTextCountsLocked(pool []map[string]any) { + for _, item := range pool { + s.textRequestCount[util.Clean(item["access_token"])] = 0 + } } func (s *AccountService) GetAvailableAccessToken(ctx context.Context) (string, error) { @@ -308,6 +662,11 @@ func (s *AccountService) GetAvailableAccessTokenFor(ctx context.Context, allow f account, refreshErr := s.RefreshAccountState(ctx, reservation.token) if refreshErr != nil { lastRefreshErr = refreshErr + if cached := s.cachedAccountForTransientRefreshError(reservation.token, refreshErr); cached != nil && + (allow == nil || allow(cached)) && + s.reservedImageSlotAvailable(reservation) { + return reservation.token, nil + } } if account != nil && (allow == nil || allow(account)) && s.reservedImageSlotAvailable(reservation) { return reservation.token, nil @@ -316,6 +675,23 @@ func (s *AccountService) GetAvailableAccessTokenFor(ctx context.Context, allow f } } +func (s *AccountService) cachedAccountForTransientRefreshError(accessToken string, err error) map[string]any { + if err == nil { + return nil + } + if _, ok := util.SummarizeUpstreamConnectionError(err.Error()); !ok { + return nil + } + account := s.GetAccount(accessToken) + if account == nil { + return nil + } + if IsImageAccountAvailable(account) { + return account + } + return nil +} + func (s *AccountService) HasAvailableAccount() bool { s.mu.Lock() defer s.mu.Unlock() @@ -338,20 +714,28 @@ func (s *AccountService) RefreshAccountState(ctx context.Context, accessToken st return s.UpdateAccount(accessToken, remote), nil } +type pendingRefreshItem struct { + accessToken string + sessionToken string +} + func (s *AccountService) RefreshAccounts(ctx context.Context, accessTokens []string) map[string]any { tokens := cleanTokens(accessTokens) if len(tokens) == 0 { return map[string]any{ - "refreshed": 0, - "errors": []map[string]string{}, - "results": []map[string]any{}, - "total": 0, - "failed": 0, - "duration_ms": 0, - "items": s.ListAccounts(), + "refreshed": 0, + "session_refreshed": 0, + "session_failed": 0, + "errors": []map[string]string{}, + "results": []map[string]any{}, + "total": 0, + "failed": 0, + "duration_ms": 0, + "items": s.ListAccounts(), } } startedAt := time.Now() + pendingRefresh := []pendingRefreshItem{} type result struct { token string info map[string]any @@ -392,6 +776,7 @@ func (s *AccountService) RefreshAccounts(ctx context.Context, accessTokens []str refreshed := 0 errors := []map[string]string{} details := make([]map[string]any, 0, len(tokens)) + detailsByToken := make(map[string]map[string]any, len(tokens)) for _, token := range tokens { res := resultsByToken[token] detail := map[string]any{ @@ -402,6 +787,7 @@ func (s *AccountService) RefreshAccounts(ctx context.Context, accessTokens []str "status": "error", "duration_ms": res.duration.Milliseconds(), } + detailsByToken[token] = detail if res.err == nil { updated := s.UpdateAccount(res.token, res.info) if updated != nil { @@ -425,6 +811,7 @@ func (s *AccountService) RefreshAccounts(ctx context.Context, accessTokens []str if normalized, handled := s.ApplyAccountError(res.token, "refresh_accounts", res.err); handled { message = normalized } + pendingSessionRefresh := false if current := s.GetAccount(res.token); current != nil { detail["account_status"] = current["status"] detail["email"] = current["email"] @@ -432,6 +819,18 @@ func (s *AccountService) RefreshAccounts(ctx context.Context, accessTokens []str detail["quota"] = current["quota"] detail["image_quota_unknown"] = current["image_quota_unknown"] detail["restore_at"] = current["restore_at"] + if util.Clean(current["status"]) == "过期待刷新" { + if st := util.Clean(current["session_token"]); st != "" { + pendingRefresh = append(pendingRefresh, pendingRefreshItem{accessToken: res.token, sessionToken: st}) + pendingSessionRefresh = true + } + } + } + detail["message"] = message + if pendingSessionRefresh { + detail["status"] = "pending_session_refresh" + details = append(details, detail) + continue } errorItem := map[string]string{ "account_id": accountIDFromToken(res.token), @@ -439,18 +838,82 @@ func (s *AccountService) RefreshAccounts(ctx context.Context, accessTokens []str "error": message, } errors = append(errors, errorItem) - detail["message"] = message detail["error"] = message details = append(details, detail) } + + refreshedCount := 0 + failedRefreshCount := 0 + if len(pendingRefresh) > 0 { + sortPendingRefreshByPriority(pendingRefresh, s) + } + for _, item := range pendingRefresh { + detail := detailsByToken[item.accessToken] + newAccessToken, newSessionToken, newExpires, err := s.refresher.RefreshToken(ctx, item.accessToken, item.sessionToken) + if err != nil { + s.UpdateAccount(item.accessToken, map[string]any{"status": "异常"}) + failedRefreshCount++ + message := fmt.Sprintf("token刷新失败: %s", err.Error()) + errors = append(errors, map[string]string{ + "account_id": accountIDFromToken(item.accessToken), + "access_token": item.accessToken, + "error": message, + }) + if detail != nil { + detail["status"] = "error" + detail["message"] = message + detail["error"] = message + detail["account_status"] = "异常" + } + continue + } + if !s.RefreshAccountViaSession(item.accessToken, newAccessToken, newSessionToken, newExpires) { + failedRefreshCount++ + message := "token刷新失败: 账号更新失败" + errors = append(errors, map[string]string{ + "account_id": accountIDFromToken(item.accessToken), + "access_token": item.accessToken, + "error": message, + }) + if detail != nil { + detail["status"] = "error" + detail["message"] = message + detail["error"] = message + } + continue + } + if info, err := s.FetchRemoteInfo(ctx, newAccessToken); err == nil { + s.UpdateAccount(newAccessToken, info) + } + if detail != nil { + detail["access_token"] = newAccessToken + detail["token_preview"] = util.AnonymizeToken(newAccessToken) + detail["success"] = true + detail["status"] = "success" + detail["message"] = "token刷新成功" + delete(detail, "error") + if current := s.GetAccount(newAccessToken); current != nil { + detail["account_status"] = current["status"] + detail["email"] = current["email"] + detail["type"] = current["type"] + detail["quota"] = current["quota"] + detail["image_quota_unknown"] = current["image_quota_unknown"] + detail["restore_at"] = current["restore_at"] + } + } + refreshedCount++ + } + return map[string]any{ - "refreshed": refreshed, - "errors": errors, - "results": details, - "total": len(tokens), - "failed": len(errors), - "duration_ms": time.Since(startedAt).Milliseconds(), - "items": s.ListAccounts(), + "refreshed": refreshed, + "session_refreshed": refreshedCount, + "session_failed": failedRefreshCount, + "errors": errors, + "results": details, + "total": len(tokens), + "failed": len(errors), + "duration_ms": time.Since(startedAt).Milliseconds(), + "items": s.ListAccounts(), } } @@ -533,6 +996,33 @@ func (s *AccountService) ApplyAccountError(accessToken, event string, err error) } func (s *AccountService) ApplyAccountErrorMessage(accessToken, event, message string) (string, bool) { + // The token is expired but may still be refreshable. + if IsAccountTokenExpiredErrorMessage(message) { + account := s.GetAccount(accessToken) + sessionToken := "" + if account != nil { + sessionToken = util.Clean(account["session_token"]) + } + if sessionToken != "" { + // Accounts with session_token refresh asynchronously during live requests; + // batch scans refresh serially in the second RefreshAccounts phase. + status := "过期待刷新" + if event != "refresh_accounts" { + status = "刷新中" + } + s.UpdateAccount(accessToken, map[string]any{"status": status}) + if event != "refresh_accounts" { + s.refreshAccountViaSessionAsync(accessToken, sessionToken) + } + return "检测到token过期,已提交刷新任务", true + } + // Accounts without session_token cannot be refreshed and become invalid. + if !s.RemoveInvalidToken(accessToken, event) { + s.UpdateAccount(accessToken, map[string]any{"status": "异常", "quota": 0, "image_quota_unknown": false}) + } + return "检测到token过期且无法刷新", true + } + // Revoked or invalidated tokens cannot be refreshed. if IsAccountInvalidErrorMessage(message) { if !s.RemoveInvalidToken(accessToken, event) { s.UpdateAccount(accessToken, map[string]any{"status": "异常", "quota": 0, "image_quota_unknown": false}) @@ -546,6 +1036,60 @@ func (s *AccountService) ApplyAccountErrorMessage(accessToken, event, message st return message, false } +// RefreshAccountViaSession updates account data after a successful session refresh. +func (s *AccountService) RefreshAccountViaSession(accessToken, newAccessToken, newSessionToken, newExpires string) bool { + accessToken = util.Clean(accessToken) + newAccessToken = util.Clean(newAccessToken) + if accessToken == "" || newAccessToken == "" { + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + + idx := s.findIndexLocked(accessToken) + if idx < 0 { + return false + } + if accessToken != newAccessToken { + if duplicateIdx := s.findIndexLocked(newAccessToken); duplicateIdx >= 0 && duplicateIdx != idx { + s.items = append(s.items[:duplicateIdx], s.items[duplicateIdx+1:]...) + if duplicateIdx < idx { + idx-- + } + } + } + + account := normalizeAccount(mergeMaps(s.items[idx], map[string]any{ + "access_token": newAccessToken, + "session_token": newSessionToken, + "session_expires": newExpires, + "status": "正常", + })) + if account == nil { + return false + } + s.items[idx] = account + if accessToken != newAccessToken { + if count, ok := s.imageReservations[accessToken]; ok { + s.imageReservations[newAccessToken] = count + delete(s.imageReservations, accessToken) + } + if count, ok := s.textRequestCount[accessToken]; ok { + s.textRequestCount[newAccessToken] = count + delete(s.textRequestCount, accessToken) + } + } + _ = s.saveLocked() + s.logs.Add("刷新账号token", map[string]any{ + "module": "accounts", + "operation_type": "更新", + "token": util.AnonymizeToken(newAccessToken), + "status": account["status"], + }) + return true +} + func (s *AccountService) FetchRemoteInfo(ctx context.Context, accessToken string) (map[string]any, error) { accessToken = util.Clean(accessToken) if accessToken == "" { @@ -663,7 +1207,7 @@ func (s *AccountService) StartLimitedWatcher(ctx context.Context, interval time. case <-ctx.Done(): return case <-timer.C: - tokens := s.ListLimitedTokens() + tokens := s.listRefreshableLimitedTokens(time.Now()) if len(tokens) > 0 { s.RefreshAccounts(ctx, tokens) } @@ -891,12 +1435,33 @@ func (s *AccountService) detectAccountType(accessToken string, mePayload, initPa return "Free" } +func sortPendingRefreshByPriority(items []pendingRefreshItem, s *AccountService) { + if len(items) < 2 { + return + } + paid := make([]pendingRefreshItem, 0, len(items)) + free := make([]pendingRefreshItem, 0, len(items)) + for _, item := range items { + if isPaidRefreshAccount(s, item.accessToken) { + paid = append(paid, item) + } else { + free = append(free, item) + } + } + copy(items, append(paid, free...)) +} + +func isPaidRefreshAccount(s *AccountService, accessToken string) bool { + account := s.GetAccount(accessToken) + return IsPaidImageAccount(account) +} + func IsImageAccountAvailable(account map[string]any) bool { if account == nil { return false } status := util.Clean(account["status"]) - if status == "禁用" || status == "限流" || status == "异常" { + if status == "禁用" || status == "限流" || status == "异常" || status == "刷新中" || status == "过期待刷新" { return false } if util.ToBool(account["image_quota_unknown"]) { @@ -922,7 +1487,23 @@ func IsAccountInvalidErrorMessage(message string) bool { return strings.Contains(text, "token_invalidated") || strings.Contains(text, "token_revoked") || strings.Contains(text, "authentication token has been invalidated") || - strings.Contains(text, "invalidated oauth token") + strings.Contains(text, "invalidated oauth token") || + strings.Contains(text, "token expired") || + strings.Contains(text, "authentication token is expired") +} + +// IsAccountTokenExpiredErrorMessage detects refreshable token-expired errors. +// Unlike IsAccountInvalidErrorMessage, it excludes non-refreshable cases such as +// token_invalidated, token_revoked, and invalidated oauth token errors. +// When this returns true and the account has session_token, refresh it instead of +// marking the account invalid immediately. +func IsAccountTokenExpiredErrorMessage(message string) bool { + text := strings.ToLower(strings.TrimSpace(message)) + if text == "" || isBootstrapErrorMessage(text) { + return false + } + return strings.Contains(text, "token expired") || + strings.Contains(text, "authentication token is expired") } func IsAccountRateLimitedErrorMessage(message string) bool { @@ -1152,6 +1733,19 @@ func extractQuotaAndRestoreAt(limits []any) (int, any, bool) { return 0, nil, true } +func parseAccountRestoreAt(value any) (time.Time, bool) { + text := util.Clean(value) + if text == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if parsed, err := time.Parse(layout, text); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + func anyList(value any) []any { if list, ok := value.([]any); ok { return list diff --git a/internal/service/account_test.go b/internal/service/account_test.go index fa7fd8253..6bbb11b62 100644 --- a/internal/service/account_test.go +++ b/internal/service/account_test.go @@ -3,16 +3,15 @@ package service import ( "context" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" - "path/filepath" "reflect" "strings" "sync" "testing" "time" - - "chatgpt2api/internal/storage" ) type testAccountConfig struct{} @@ -249,6 +248,226 @@ func TestRefreshAccountStateMarksUnauthorizedInitAsInvalid(t *testing.T) { } } +func TestAddAccountFromSessionUpdatesExistingUserWhenAccessTokenRotates(t *testing.T) { + accounts := newTestAccountService(t) + accounts.refresher = NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"new-access-token","sessionToken":"new-session-token","expires":"2026-05-12T00:00:00Z","user":{"id":"user-123","email":"user@example.com","name":"New Name"}}`)), + }, nil + }) + accounts.AddAccounts([]string{"old-access-token"}) + accounts.UpdateAccount("old-access-token", map[string]any{ + "user_id": "user-123", + "email": "user@example.com", + "name": "Old Name", + "type": "Plus", + "quota": 7, + "status": "禁用", + }) + + result, err := accounts.AddAccountFromSession(`{ + "accessToken":"new-access-token", + "sessionToken":"new-session-token", + "expires":"2026-05-12T00:00:00Z", + "user":{"id":"user-123","email":"user@example.com","name":"New Name"} + }`) + if err != nil { + t.Fatalf("AddAccountFromSession() error = %v", err) + } + if result["added"] != 0 || result["updated"] != 1 { + t.Fatalf("AddAccountFromSession() result = %#v, want updated existing account", result) + } + if old := accounts.GetAccount("old-access-token"); old != nil { + t.Fatalf("old token account still exists: %#v", old) + } + updated := accounts.GetAccount("new-access-token") + if updated == nil { + t.Fatalf("new token account missing") + } + if len(accounts.items) != 1 { + t.Fatalf("account count = %d, want 1: %#v", len(accounts.items), accounts.items) + } + if updated["session_token"] != "new-session-token" || updated["session_expires"] != "2026-05-12T00:00:00Z" { + t.Fatalf("session fields not updated: %#v", updated) + } + if updated["type"] != "Plus" || updated["quota"] != 7 || updated["status"] != "禁用" { + t.Fatalf("existing account metadata not preserved: %#v", updated) + } + if updated["name"] != "New Name" || updated["email"] != "user@example.com" || updated["user_id"] != "user-123" { + t.Fatalf("session identity fields not updated: %#v", updated) + } +} + +func TestAddAccountFromSessionUsesValidatedIdentityForMatching(t *testing.T) { + accounts := newTestAccountService(t) + accounts.refresher = NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"validated-access-token","sessionToken":"validated-session-token","expires":"2026-05-13T00:00:00Z","user":{"id":"validated-user","email":"validated@example.com","name":"Validated Name"}}`)), + }, nil + }) + accounts.AddAccounts([]string{"old-access-token"}) + accounts.UpdateAccount("old-access-token", map[string]any{ + "user_id": "validated-user", + "email": "validated@example.com", + "type": "Plus", + "status": "异常", + }) + + result, err := accounts.AddAccountFromSession(`{ + "accessToken":"submitted-access-token", + "sessionToken":"submitted-session-token", + "expires":"2026-05-12T00:00:00Z", + "user":{"id":"attacker-user","email":"attacker@example.com","name":"Attacker Name"} + }`) + if err != nil { + t.Fatalf("AddAccountFromSession() error = %v", err) + } + if result["updated"] != 1 || result["added"] != 0 { + t.Fatalf("AddAccountFromSession() result = %#v, want validated identity update", result) + } + if len(accounts.items) != 1 { + t.Fatalf("account count = %d, want 1: %#v", len(accounts.items), accounts.items) + } + updated := accounts.GetAccount("validated-access-token") + if updated == nil { + t.Fatalf("validated token account missing") + } + if updated["user_id"] != "validated-user" || updated["email"] != "validated@example.com" || updated["name"] != "Validated Name" { + t.Fatalf("submitted identity was used instead of validated identity: %#v", updated) + } + if updated["status"] != "正常" || updated["type"] != "Plus" { + t.Fatalf("validated account metadata not preserved: %#v", updated) + } +} + +func TestAddAccountFromSessionValidatesSessionBeforeRecoveringAbnormalAccount(t *testing.T) { + accounts := newTestAccountService(t) + accounts.refresher = NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"refreshed-access-token","sessionToken":"refreshed-session-token","expires":"2026-05-13T00:00:00Z","user":{"id":"user-123","email":"user@example.com","name":"Recovered Name"}}`)), + }, nil + }) + accounts.AddAccounts([]string{"old-access-token"}) + accounts.UpdateAccount("old-access-token", map[string]any{ + "user_id": "user-123", + "email": "user@example.com", + "type": "Plus", + "quota": 0, + "status": "异常", + }) + + result, err := accounts.AddAccountFromSession(`{ + "accessToken":"submitted-access-token", + "sessionToken":"submitted-session-token", + "expires":"2026-05-12T00:00:00Z", + "user":{"id":"user-123","email":"user@example.com","name":"Recovered Name"} + }`) + if err != nil { + t.Fatalf("AddAccountFromSession() error = %v", err) + } + if result["updated"] != 1 { + t.Fatalf("AddAccountFromSession() result = %#v, want updated existing account", result) + } + if old := accounts.GetAccount("old-access-token"); old != nil { + t.Fatalf("old token account still exists: %#v", old) + } + updated := accounts.GetAccount("refreshed-access-token") + if updated == nil { + t.Fatalf("refreshed token account missing") + } + if len(accounts.items) != 1 { + t.Fatalf("account count = %d, want 1: %#v", len(accounts.items), accounts.items) + } + if updated["session_token"] != "refreshed-session-token" || updated["session_expires"] != "2026-05-13T00:00:00Z" { + t.Fatalf("validated session fields not stored: %#v", updated) + } + if updated["status"] != "正常" || updated["type"] != "Plus" { + t.Fatalf("abnormal account not recovered with metadata preserved: %#v", updated) + } +} + +func TestAddAccountFromSessionRecoversAbnormalAccountWhenAccessTokenMatches(t *testing.T) { + accounts := newTestAccountService(t) + accounts.refresher = NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"same-access-token","sessionToken":"fresh-session-token","expires":"2026-05-13T00:00:00Z","user":{"id":"user-123","email":"user@example.com","name":"Recovered Name"}}`)), + }, nil + }) + accounts.AddAccounts([]string{"same-access-token"}) + accounts.UpdateAccount("same-access-token", map[string]any{ + "user_id": "user-123", + "email": "user@example.com", + "type": "Plus", + "status": "异常", + }) + + result, err := accounts.AddAccountFromSession(`{ + "accessToken":"same-access-token", + "sessionToken":"submitted-session-token", + "expires":"2026-05-12T00:00:00Z", + "user":{"id":"user-123","email":"user@example.com","name":"Recovered Name"} + }`) + if err != nil { + t.Fatalf("AddAccountFromSession() error = %v", err) + } + if result["updated"] != 1 { + t.Fatalf("AddAccountFromSession() result = %#v, want updated existing account", result) + } + updated := accounts.GetAccount("same-access-token") + if updated == nil { + t.Fatalf("same token account missing") + } + if updated["status"] != "正常" || updated["session_token"] != "fresh-session-token" || updated["session_expires"] != "2026-05-13T00:00:00Z" { + t.Fatalf("account not recovered with validated session fields: %#v", updated) + } +} + +func TestAddAccountFromSessionRejectsInvalidSessionWithoutMutatingExistingAccount(t *testing.T) { + accounts := newTestAccountService(t) + accounts.refresher = NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"detail":"invalid session"}`)), + }, nil + }) + accounts.AddAccounts([]string{"old-access-token"}) + accounts.UpdateAccount("old-access-token", map[string]any{ + "user_id": "user-123", + "email": "user@example.com", + "type": "Plus", + "quota": 3, + "status": "异常", + "session_token": "old-session-token", + }) + + _, err := accounts.AddAccountFromSession(`{ + "accessToken":"submitted-access-token", + "sessionToken":"bad-session-token", + "expires":"2026-05-12T00:00:00Z", + "user":{"id":"user-123","email":"user@example.com","name":"Bad Session"} + }`) + if err == nil || !strings.Contains(err.Error(), "session token validation failed") { + t.Fatalf("AddAccountFromSession() error = %v, want validation failure", err) + } + if len(accounts.items) != 1 { + t.Fatalf("account count = %d, want unchanged single account: %#v", len(accounts.items), accounts.items) + } + if created := accounts.GetAccount("submitted-access-token"); created != nil { + t.Fatalf("invalid session created new account: %#v", created) + } + unchanged := accounts.GetAccount("old-access-token") + if unchanged == nil { + t.Fatalf("old account missing after invalid session import") + } + if unchanged["status"] != "异常" || unchanged["session_token"] != "old-session-token" || unchanged["quota"] != 3 { + t.Fatalf("old account mutated after invalid session import: %#v", unchanged) + } +} + func TestApplyAccountErrorMessageDoesNotMarkGenericUnauthorizedAsInvalid(t *testing.T) { accounts := newTestAccountService(t) accounts.AddAccounts([]string{"token-1"}) @@ -478,6 +697,27 @@ func TestGetAvailableAccessTokenReportsRefreshFailure(t *testing.T) { } } +func TestGetAvailableAccessTokenUsesCachedAccountOnConnectionRefreshFailure(t *testing.T) { + accounts := newTestAccountService(t) + accounts.browserHTTPClient = func(string, time.Duration) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New(`Get "https://chatgpt.com/": surf: HTTP/2 request failed: uTLS.HandshakeContext() error: EOF; HTTP/1.1 fallback failed: uTLS.HandshakeContext() error: EOF`) + }), + } + } + accounts.AddAccounts([]string{"token-1"}) + accounts.UpdateAccount("token-1", map[string]any{"status": "正常", "quota": 1, "type": "Plus"}) + + token, err := accounts.GetAvailableAccessToken(context.Background()) + if err != nil { + t.Fatalf("GetAvailableAccessToken() error = %v", err) + } + if token != "token-1" { + t.Fatalf("token = %q, want cached token-1", token) + } +} + func TestReserveNextCandidateTokenCanFilterPaidAccounts(t *testing.T) { accounts := newTestAccountService(t) accounts.AddAccounts([]string{"free-token", "plus-token"}) @@ -537,6 +777,61 @@ func TestApplyAccountErrorMessageIgnoresBootstrapFailures(t *testing.T) { } } +func TestStartLimitedWatcherSkipsAccountBeforeRestoreTime(t *testing.T) { + var mu sync.Mutex + meCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/backend-api/me" { + mu.Lock() + meCalls++ + mu.Unlock() + } + switch r.URL.Path { + case "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + case "/backend-api/me": + writeJSON(t, w, map[string]any{"email": "user@example.com", "id": "user-1"}) + case "/backend-api/conversation/init": + writeJSON(t, w, map[string]any{ + "default_model_slug": "gpt-5", + "limits_progress": []map[string]any{{ + "feature_name": "image_gen", + "remaining": 0, + "reset_after": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + accounts := newTestAccountService(t) + accounts.remoteBaseURL = server.URL + accounts.browserHTTPClient = func(string, time.Duration) *http.Client { + return server.Client() + } + accounts.AddAccounts([]string{"token-1"}) + accounts.UpdateAccount("token-1", map[string]any{ + "status": "限流", + "quota": 0, + "restore_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + accounts.StartLimitedWatcher(ctx, 20*time.Millisecond) + time.Sleep(80 * time.Millisecond) + + mu.Lock() + got := meCalls + mu.Unlock() + if got != 0 { + t.Fatalf("limited watcher refreshed account before restore time: /backend-api/me calls = %d, want 0", got) + } +} + func TestSummarizeRefreshErrorBodyPrefersJSONMessage(t *testing.T) { got := summarizeRefreshErrorBody([]byte(`{"error":{"message":"You've reached the image generation limit"}}`)) if got != "body=You've reached the image generation limit" { @@ -546,12 +841,12 @@ func TestSummarizeRefreshErrorBodyPrefersJSONMessage(t *testing.T) { func newTestAccountService(t *testing.T) *AccountService { t.Helper() - dir := t.TempDir() + backend := newTestStorageBackend(t) return NewAccountService( - storage.NewJSONBackend(filepath.Join(dir, "accounts.json"), filepath.Join(dir, "auth_keys.json")), + backend, testAccountConfig{}, NewProxyService(testAccountConfig{}), - NewLogService(dir), + NewLogService(backend), ) } diff --git a/internal/service/announcement.go b/internal/service/announcement.go index 765872cde..4d19cf74d 100644 --- a/internal/service/announcement.go +++ b/internal/service/announcement.go @@ -1,7 +1,6 @@ package service import ( - "path/filepath" "sync" "chatgpt2api/internal/storage" @@ -10,14 +9,13 @@ import ( type AnnouncementService struct { mu sync.Mutex - path string store storage.JSONDocumentBackend items []map[string]any docName string } -func NewAnnouncementService(dataDir string, backend ...storage.Backend) *AnnouncementService { - s := &AnnouncementService{path: filepath.Join(dataDir, "announcements.json"), store: firstJSONDocumentStore(backend), docName: "announcements.json"} +func NewAnnouncementService(backend ...storage.Backend) *AnnouncementService { + s := &AnnouncementService{store: firstJSONDocumentStore(backend), docName: "announcements.json"} s.items = s.load() return s } @@ -108,7 +106,7 @@ func (s *AnnouncementService) Delete(id string) bool { } func (s *AnnouncementService) load() []map[string]any { - raw := loadStoredJSON(s.store, s.docName, s.path) + raw := loadStoredJSON(s.store, s.docName) items := make([]map[string]any, 0) for _, item := range anyList(raw) { if itemMap, ok := item.(map[string]any); ok { @@ -122,7 +120,7 @@ func (s *AnnouncementService) load() []map[string]any { } func (s *AnnouncementService) saveLocked() error { - return saveStoredJSON(s.store, s.docName, s.path, s.items) + return saveStoredJSON(s.store, s.docName, s.items) } func normalizeAnnouncement(raw map[string]any) map[string]any { diff --git a/internal/service/auth.go b/internal/service/auth.go index fd3bd5dff..2bed83acc 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -26,6 +26,8 @@ const ( rbacRolesDocumentName = "rbac_roles.json" ) +var ErrAuthUserCreationDisabled = authError("auth user creation is disabled") + type Identity struct { ID string Name string @@ -97,6 +99,7 @@ type AuthService struct { items []map[string]any roles []ManagedRole lastUsedFlushAt map[string]time.Time + onUserCreated func(string) } func NewAuthService(backend storage.Backend) *AuthService { @@ -109,6 +112,25 @@ func NewAuthService(backend storage.Backend) *AuthService { return s } +func (s *AuthService) SetUserCreatedHook(fn func(string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onUserCreated = fn +} + +func (s *AuthService) notifyUserCreated(userID string) { + userID = util.Clean(userID) + if userID == "" { + return + } + s.mu.Lock() + fn := s.onUserCreated + s.mu.Unlock() + if fn != nil { + fn(userID) + } +} + func (s *AuthService) ListKeys(filter AuthKeyFilter) []map[string]any { filter = normalizeAuthKeyFilter(filter) s.mu.Lock() @@ -365,9 +387,10 @@ func (s *AuthService) UpsertAPIKeyForOwner(name string, owner AuthOwner) (map[st now := util.NowISO() s.mu.Lock() - defer s.mu.Unlock() nextItems := make([]map[string]any, 0, len(s.items)+1) var updated map[string]any + createdUserID := "" + ownerExists := managedUserExistsLocked(s.items, s.accounts, owner.ID) for _, item := range s.items { matchesOwnerAPIKey := util.Clean(item["role"]) == AuthRoleUser && util.Clean(item["kind"]) == AuthKindAPIKey && @@ -398,12 +421,19 @@ func (s *AuthService) UpsertAPIKeyForOwner(name string, owner AuthOwner) (map[st s.applyRoleToAuthItem(updated, "") } nextItems = append(nextItems, updated) + if !ownerExists { + createdUserID = managedAuthUserID(updated) + } } s.items = nextItems if err := s.saveLocked(); err != nil { + s.mu.Unlock() return nil, "", err } - return publicAuthItem(updated), raw, nil + item := publicAuthItem(updated) + s.mu.Unlock() + s.notifyUserCreated(createdUserID) + return item, raw, nil } func (s *AuthService) UpsertPersonalAPIKey(identity Identity, name string) (map[string]any, string, error) { @@ -458,6 +488,14 @@ func (s *AuthService) UpsertPersonalAPIKey(identity Identity, name string) (map[ } func (s *AuthService) UpsertLinuxDoSession(owner AuthOwner) (map[string]any, string, error) { + return s.upsertLinuxDoSession(owner, true) +} + +func (s *AuthService) UpsertLinuxDoSessionIfAllowed(owner AuthOwner, allowCreate bool) (map[string]any, string, error) { + return s.upsertLinuxDoSession(owner, allowCreate) +} + +func (s *AuthService) upsertLinuxDoSession(owner AuthOwner, allowCreate bool) (map[string]any, string, error) { owner.ID = util.Clean(owner.ID) owner.Name = util.Clean(owner.Name) owner.Provider = AuthProviderLinuxDo @@ -472,7 +510,6 @@ func (s *AuthService) UpsertLinuxDoSession(owner AuthOwner) (map[string]any, str now := util.NowISO() s.mu.Lock() - defer s.mu.Unlock() sessionEnabled := true ownerSeen := false ownerHasEnabled := false @@ -505,9 +542,16 @@ func (s *AuthService) UpsertLinuxDoSession(owner AuthOwner) (map[string]any, str next["updated_at"] = now s.items[index] = next if err := s.saveLocked(); err != nil { + s.mu.Unlock() return nil, "", err } - return publicAuthItem(next), raw, nil + item := publicAuthItem(next) + s.mu.Unlock() + return item, raw, nil + } + if !ownerSeen && !allowCreate { + s.mu.Unlock() + return nil, "", ErrAuthUserCreationDisabled } item := newAuthItem(AuthRoleUser, AuthKindSession, name, owner, raw) @@ -519,9 +563,17 @@ func (s *AuthService) UpsertLinuxDoSession(owner AuthOwner) (map[string]any, str item["enabled"] = sessionEnabled s.items = append(s.items, item) if err := s.saveLocked(); err != nil { + s.mu.Unlock() return nil, "", err } - return publicAuthItem(item), raw, nil + public := publicAuthItem(item) + createdUserID := "" + if !ownerSeen { + createdUserID = managedAuthUserID(item) + } + s.mu.Unlock() + s.notifyUserCreated(createdUserID) + return public, raw, nil } func (s *AuthService) RevealKey(id string, filter AuthKeyFilter) (string, bool) { @@ -1014,13 +1066,21 @@ func (s *AuthService) createCredential(role, kind, name string, owner AuthOwner, raw := prefix + util.RandomTokenURL(24) item := newAuthItem(role, kind, name, owner, raw) s.mu.Lock() - defer s.mu.Unlock() + userID := managedAuthUserID(item) + createdUserID := "" + if userID != "" && !managedUserExistsLocked(s.items, s.accounts, userID) { + createdUserID = userID + } s.applyRoleToAuthItem(item, "") s.items = append(s.items, item) if err := s.saveLocked(); err != nil { + s.mu.Unlock() return nil, "", err } - return publicAuthItem(item), raw, nil + public := publicAuthItem(item) + s.mu.Unlock() + s.notifyUserCreated(createdUserID) + return public, raw, nil } func newAuthItem(role, kind, name string, owner AuthOwner, raw string) map[string]any { @@ -1238,30 +1298,7 @@ func listManagedAuthUsersLocked(items []map[string]any, roles []ManagedRole, acc } user := byID[id] if user == nil { - user = map[string]any{ - "id": id, - "name": managedAuthUserName(item), - "role": AuthRoleUser, - "role_id": DefaultManagedRoleID, - "role_name": managedRoleName(roles, DefaultManagedRoleID), - "provider": util.Clean(item["provider"]), - "owner_id": util.Clean(item["owner_id"]), - "owner_name": util.Clean(item["owner_name"]), - "linuxdo_level": util.Clean(item["linuxdo_level"]), - "enabled": false, - "has_api_key": false, - "has_session": false, - "api_key_id": "", - "api_key_name": "", - "session_id": "", - "session_name": "", - "credential_count": 0, - "created_at": nil, - "last_used_at": nil, - "updated_at": nil, - "menu_paths": []string{}, - "api_permissions": []string{}, - } + user = managedAuthUserForItem(item, roles) byID[id] = user } mergeManagedAuthUser(user, item) @@ -1286,6 +1323,34 @@ func listManagedAuthUsersLocked(items []map[string]any, roles []ManagedRole, acc return out } +func managedAuthUserForItem(item map[string]any, roles []ManagedRole) map[string]any { + id := managedAuthUserID(item) + return map[string]any{ + "id": id, + "name": managedAuthUserName(item), + "role": AuthRoleUser, + "role_id": DefaultManagedRoleID, + "role_name": managedRoleName(roles, DefaultManagedRoleID), + "provider": util.Clean(item["provider"]), + "owner_id": util.Clean(item["owner_id"]), + "owner_name": util.Clean(item["owner_name"]), + "linuxdo_level": util.Clean(item["linuxdo_level"]), + "enabled": false, + "has_api_key": false, + "has_session": false, + "api_key_id": "", + "api_key_name": "", + "session_id": "", + "session_name": "", + "credential_count": 0, + "created_at": nil, + "last_used_at": nil, + "updated_at": nil, + "menu_paths": []string{}, + "api_permissions": []string{}, + } +} + func managedAuthUserForAccount(account PasswordAccount, roles []ManagedRole) map[string]any { roleID := account.ManagedRoleID() roleName := managedRoleName(roles, roleID) @@ -1321,12 +1386,24 @@ func managedAuthUserForAccount(account PasswordAccount, roles []ManagedRole) map } func managedAuthUserByIDLocked(items []map[string]any, roles []ManagedRole, accounts []PasswordAccount, id string) map[string]any { - for _, user := range listManagedAuthUsersLocked(items, roles, accounts) { - if user["id"] == id { - return user + id = util.Clean(id) + if id == "" { + return nil + } + var user map[string]any + if account, ok := passwordAccountByIDLocked(accounts, id); ok && account.Role == AuthRoleUser && account.ID != "" { + user = managedAuthUserForAccount(account, roles) + } + for _, item := range items { + if managedAuthUserID(item) != id { + continue + } + if user == nil { + user = managedAuthUserForItem(item, roles) } + mergeManagedAuthUser(user, item) } - return nil + return user } func managedAuthOwnerLocked(items []map[string]any, accounts []PasswordAccount, id string) (AuthOwner, bool) { @@ -1480,6 +1557,22 @@ func managedAuthRoleIDLocked(items []map[string]any, accounts []PasswordAccount, return "", false } +func managedUserExistsLocked(items []map[string]any, accounts []PasswordAccount, id string) bool { + id = util.Clean(id) + if id == "" { + return false + } + if account, ok := passwordAccountByIDLocked(accounts, id); ok && account.Role == AuthRoleUser { + return true + } + for _, item := range items { + if managedAuthUserID(item) == id { + return true + } + } + return false +} + func normalizeManagedRoles(raw any) []ManagedRole { items := util.AsMapSlice(raw) if obj, ok := raw.(map[string]any); ok { diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index be824043f..b2ec5e415 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -1,17 +1,9 @@ package service -import ( - "path/filepath" - "testing" - - "chatgpt2api/internal/storage" -) +import "testing" func TestAuthServiceCreateAuthenticateDisableAndDelete(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) filter := AuthKeyFilter{Role: AuthRoleUser, Kind: AuthKindAPIKey} @@ -63,10 +55,7 @@ func TestAuthServiceCreateAuthenticateDisableAndDelete(t *testing.T) { } func TestAuthServiceAssignsManagedRolesToUsers(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) user, raw, err := auth.CreateAPIKey(AuthRoleUser, "operator", AuthOwner{}) @@ -123,10 +112,7 @@ func TestAuthServiceAssignsManagedRolesToUsers(t *testing.T) { } func TestAuthServicePasswordAccountLoginAndRoleUpdates(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) bootstrap, err := auth.EnsureBootstrapAdmin("admin", "AdminPass123!") @@ -209,10 +195,7 @@ func TestAuthServicePasswordAccountLoginAndRoleUpdates(t *testing.T) { } func TestAuthServiceLinuxDoSessionOwnsAPIKeys(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) owner := AuthOwner{ID: "linuxdo:123", Name: "linuxdo_user", Provider: AuthProviderLinuxDo, LinuxDoLevel: "3"} @@ -254,11 +237,37 @@ func TestAuthServiceLinuxDoSessionOwnsAPIKeys(t *testing.T) { } } +func TestAuthServiceUpsertLinuxDoSessionHonorsCreateGate(t *testing.T) { + backend := newTestStorageBackend(t) + auth := NewAuthService(backend) + + owner := AuthOwner{ID: "linuxdo:blocked", Name: "blocked_user", Provider: AuthProviderLinuxDo, LinuxDoLevel: "1"} + if _, _, err := auth.UpsertLinuxDoSessionIfAllowed(owner, false); err != ErrAuthUserCreationDisabled { + t.Fatalf("UpsertLinuxDoSessionIfAllowed(disallow new) error = %v, want %v", err, ErrAuthUserCreationDisabled) + } + if user := findAuthUser(auth.ListUsers(), owner.ID); user != nil { + t.Fatalf("disallowed linuxdo session created user: %#v", user) + } + + created, createdRaw, err := auth.UpsertLinuxDoSessionIfAllowed(owner, true) + if err != nil || createdRaw == "" { + t.Fatalf("UpsertLinuxDoSessionIfAllowed(allow new) raw=%q err=%v", createdRaw, err) + } + if created["owner_id"] != owner.ID { + t.Fatalf("created linuxdo session = %#v", created) + } + + next, nextRaw, err := auth.UpsertLinuxDoSessionIfAllowed(owner, false) + if err != nil || nextRaw == "" { + t.Fatalf("UpsertLinuxDoSessionIfAllowed(existing, disallow new) raw=%q err=%v", nextRaw, err) + } + if next["id"] != created["id"] { + t.Fatalf("existing linuxdo session should be updated, created=%#v next=%#v", created, next) + } +} + func TestAuthServiceUpsertAPIKeyForOwnerKeepsOneToken(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) owner := AuthOwner{ID: "linuxdo:123", Name: "linuxdo_user", Provider: AuthProviderLinuxDo, LinuxDoLevel: "3"} @@ -293,10 +302,7 @@ func TestAuthServiceUpsertAPIKeyForOwnerKeepsOneToken(t *testing.T) { } func TestAuthServiceListSingleAPIKeyForOwnerPrunesDuplicates(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) owner := AuthOwner{ID: "linuxdo:123", Name: "linuxdo_user", Provider: AuthProviderLinuxDo, LinuxDoLevel: "3"} @@ -321,10 +327,7 @@ func TestAuthServiceListSingleAPIKeyForOwnerPrunesDuplicates(t *testing.T) { } func TestAuthServiceManagedUsersGroupAndControlCredentials(t *testing.T) { - backend := storage.NewJSONBackend( - filepath.Join(t.TempDir(), "accounts.json"), - filepath.Join(t.TempDir(), "auth_keys.json"), - ) + backend := newTestStorageBackend(t) auth := NewAuthService(backend) owner := AuthOwner{ID: "linuxdo:123", Name: "linuxdo_user", Provider: AuthProviderLinuxDo, LinuxDoLevel: "3"} diff --git a/internal/service/billing.go b/internal/service/billing.go new file mode 100644 index 000000000..e1fb88386 --- /dev/null +++ b/internal/service/billing.go @@ -0,0 +1,1144 @@ +package service + +import ( + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "chatgpt2api/internal/storage" + "chatgpt2api/internal/util" +) + +const ( + BillingTypeStandard = "standard" + BillingTypeSubscription = "subscription" + + BillingUnitImage = "image" + + BillingPeriodDaily = "daily" + BillingPeriodWeekly = "weekly" + BillingPeriodMonthly = "monthly" + + billingDocumentName = "user_billing.json" +) + +type BillingDefaults interface { + DefaultBillingType() string + DefaultStandardBalance() int + DefaultSubscriptionQuota() int + DefaultSubscriptionPeriod() string +} + +type BillingReference struct { + Endpoint string + Model string + TaskID string + RequestID string + CredentialID string + CredentialName string + ChargeKey string + RefundForKey string + OutputIndex int +} + +type BillingChargeResult struct { + Charged bool + AlreadyCharged bool + Billing map[string]any +} + +type BillingRefundResult struct { + Refunded bool + AlreadyRefunded bool + Billing map[string]any +} + +type BillingBulkAdjustmentResult struct { + UserID string + Billing map[string]any + Adjustment map[string]any + Error string +} + +type BillingLimitError struct { + BillingType string + Message string + Code string +} + +func (e BillingLimitError) Error() string { + return e.Message +} + +func (e BillingLimitError) OpenAIError() map[string]any { + return map[string]any{ + "error": map[string]any{ + "message": e.Message, + "type": "insufficient_quota", + "param": nil, + "code": e.Code, + }, + } +} + +func NewBillingLimitError(billingType string) BillingLimitError { + if normalizeBillingType(billingType) == BillingTypeSubscription { + return BillingLimitError{ + BillingType: BillingTypeSubscription, + Message: "user quota exceeded", + Code: "user_quota_exceeded", + } + } + return BillingLimitError{ + BillingType: BillingTypeStandard, + Message: "user balance insufficient", + Code: "user_balance_insufficient", + } +} + +type BillingService struct { + mu sync.Mutex + store storage.JSONDocumentBackend + defaults BillingDefaults + + states map[string]map[string]any + adjustments []map[string]any + transactions []map[string]any +} + +func NewBillingService(backend storage.Backend, defaults BillingDefaults) *BillingService { + s := &BillingService{ + store: jsonDocumentStoreFromBackend(backend), + defaults: defaults, + states: map[string]map[string]any{}, + } + s.mu.Lock() + s.loadLocked() + s.mu.Unlock() + return s +} + +func (s *BillingService) InitializeUserDefaults(userID string) map[string]any { + userID = strings.TrimSpace(userID) + if s == nil || userID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.states == nil { + s.states = map[string]map[string]any{} + } + state := s.states[userID] + changed := false + if state == nil { + state = defaultBillingState(userID, s.defaults) + s.states[userID] = state + changed = true + } else { + changed = normalizeBillingState(state, userID, nil) + } + if changed { + _ = s.saveLocked() + } + return publicBillingState(state) +} + +func (s *BillingService) Get(userID string) map[string]any { + userID = strings.TrimSpace(userID) + if userID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + state, ok := s.stateForReadLocked(userID) + if !ok { + return publicBillingState(legacyBillingState(userID)) + } + changed := false + if normalizeBillingState(state, userID, nil) { + changed = true + } + if s.resetSubscriptionIfDueLocked(state, time.Now()) { + changed = true + } + if changed { + _ = s.saveLocked() + } + return publicBillingState(state) +} + +func (s *BillingService) GetMany(userIDs []string) map[string]map[string]any { + out := map[string]map[string]any{} + if len(userIDs) == 0 { + return out + } + s.mu.Lock() + defer s.mu.Unlock() + changed := false + now := time.Now() + for _, userID := range userIDs { + userID = strings.TrimSpace(userID) + if userID == "" { + continue + } + state, ok := s.stateForReadLocked(userID) + if !ok { + out[userID] = publicBillingState(legacyBillingState(userID)) + continue + } + if normalizeBillingState(state, userID, nil) { + changed = true + } + if s.resetSubscriptionIfDueLocked(state, now) { + changed = true + } + out[userID] = publicBillingState(state) + } + if changed { + _ = s.saveLocked() + } + return out +} + +func (s *BillingService) CheckAvailable(identity Identity, amount int) error { + if s == nil || identity.Role != AuthRoleUser || amount <= 0 { + return nil + } + userID := billingUserID(identity) + if userID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + state, changed := s.ensureStateLocked(userID) + if s.resetSubscriptionIfDueLocked(state, time.Now()) { + changed = true + } + if util.ToBool(state["unlimited"]) { + if changed { + _ = s.saveLocked() + } + return nil + } + billingType := normalizeBillingType(util.Clean(state["billing_type"])) + switch billingType { + case BillingTypeStandard: + standard := billingStandardState(state) + if availableStandardBalance(standard) < amount { + if changed { + _ = s.saveLocked() + } + return NewBillingLimitError(BillingTypeStandard) + } + case BillingTypeSubscription: + subscription := billingSubscriptionState(state) + if availableSubscriptionQuota(subscription) < amount { + if changed { + _ = s.saveLocked() + } + return NewBillingLimitError(BillingTypeSubscription) + } + default: + return fmt.Errorf("unsupported billing type: %s", billingType) + } + if changed { + _ = s.saveLocked() + } + return nil +} + +func (s *BillingService) Charge(identity Identity, amount int, ref BillingReference) error { + if identity.Role != AuthRoleUser { + return nil + } + _, err := s.ChargeUserID(billingUserID(identity), amount, ref) + return err +} + +func (s *BillingService) ChargeUserID(userID string, amount int, ref BillingReference) (BillingChargeResult, error) { + return s.chargeUserID(strings.TrimSpace(userID), amount, ref) +} + +func (s *BillingService) chargeUserID(userID string, amount int, ref BillingReference) (BillingChargeResult, error) { + result := BillingChargeResult{} + if s == nil || userID == "" || amount <= 0 { + return result, nil + } + s.mu.Lock() + defer s.mu.Unlock() + state, _ := s.ensureStateLocked(userID) + s.resetSubscriptionIfDueLocked(state, time.Now()) + if util.ToBool(state["unlimited"]) { + _ = s.saveLocked() + result.Billing = publicBillingState(state) + return result, nil + } + chargeKey := strings.TrimSpace(ref.ChargeKey) + if chargeKey != "" && s.hasChargeKeyLocked(userID, chargeKey) { + result.AlreadyCharged = true + result.Billing = publicBillingState(state) + return result, nil + } + billingType := normalizeBillingType(util.Clean(state["billing_type"])) + switch billingType { + case BillingTypeStandard: + standard := billingStandardState(state) + if availableStandardBalance(standard) < amount { + return result, NewBillingLimitError(BillingTypeStandard) + } + standard["balance"] = intField(standard, "balance") - amount + standard["lifetime_consumed"] = intField(standard, "lifetime_consumed") + amount + case BillingTypeSubscription: + subscription := billingSubscriptionState(state) + if availableSubscriptionQuota(subscription) < amount { + return result, NewBillingLimitError(BillingTypeSubscription) + } + subscription["quota_used"] = intField(subscription, "quota_used") + amount + default: + return result, fmt.Errorf("unsupported billing type: %s", billingType) + } + state["updated_at"] = util.NowISO() + s.addTransactionLocked(map[string]any{ + "user_id": userID, + "billing_type": billingType, + "unit": BillingUnitImage, + "action": "charge", + "consumed_amount": amount, + "charge_key": chargeKey, + "endpoint": ref.Endpoint, + "model": ref.Model, + "task_id": ref.TaskID, + "request_id": ref.RequestID, + "output_index": ref.OutputIndex, + }) + result.Charged = true + result.Billing = publicBillingState(state) + if err := s.saveLocked(); err != nil { + return result, err + } + return result, nil +} + +func (s *BillingService) RefundUserID(userID string, amount int, ref BillingReference) (BillingRefundResult, error) { + result := BillingRefundResult{} + userID = strings.TrimSpace(userID) + if s == nil || userID == "" || amount <= 0 { + return result, nil + } + s.mu.Lock() + defer s.mu.Unlock() + state, _ := s.ensureStateLocked(userID) + s.resetSubscriptionIfDueLocked(state, time.Now()) + if util.ToBool(state["unlimited"]) { + result.Billing = publicBillingState(state) + return result, nil + } + refundKey := strings.TrimSpace(ref.ChargeKey) + if refundKey != "" && s.hasRefundKeyLocked(userID, refundKey) { + result.AlreadyRefunded = true + result.Billing = publicBillingState(state) + return result, nil + } + refundForKey := strings.TrimSpace(ref.RefundForKey) + amount = s.refundableAmountLocked(userID, amount, refundForKey) + if amount <= 0 { + result.Billing = publicBillingState(state) + return result, nil + } + billingType := normalizeBillingType(util.Clean(state["billing_type"])) + switch billingType { + case BillingTypeStandard: + standard := billingStandardState(state) + standard["balance"] = intField(standard, "balance") + amount + standard["lifetime_consumed"] = max(0, intField(standard, "lifetime_consumed")-amount) + case BillingTypeSubscription: + subscription := billingSubscriptionState(state) + subscription["quota_used"] = max(0, intField(subscription, "quota_used")-amount) + default: + return result, fmt.Errorf("unsupported billing type: %s", billingType) + } + state["updated_at"] = util.NowISO() + s.addTransactionLocked(map[string]any{ + "user_id": userID, + "billing_type": billingType, + "unit": BillingUnitImage, + "action": "refund", + "refunded_amount": amount, + "charge_key": refundKey, + "refund_for_charge_key": refundForKey, + "endpoint": ref.Endpoint, + "model": ref.Model, + "task_id": ref.TaskID, + "request_id": ref.RequestID, + "output_index": ref.OutputIndex, + }) + result.Refunded = true + result.Billing = publicBillingState(state) + if err := s.saveLocked(); err != nil { + return result, err + } + return result, nil +} + +func (s *BillingService) ApplyAdjustment(userID string, operator Identity, body map[string]any) (map[string]any, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, errors.New("user id is required") + } + adjustmentType := strings.TrimSpace(util.Clean(body["type"])) + if adjustmentType == "" { + return nil, errors.New("adjustment type is required") + } + reason := strings.TrimSpace(util.Clean(body["reason"])) + + s.mu.Lock() + defer s.mu.Unlock() + state, _ := s.ensureStateLocked(userID) + now := time.Now() + s.resetSubscriptionIfDueLocked(state, now) + before := publicBillingState(state) + amount := adjustmentAmount(body) + + if err := s.applyAdjustmentLocked(state, adjustmentType, amount, body, now); err != nil { + return nil, err + } + + state["billing_type"] = normalizeBillingType(util.Clean(state["billing_type"])) + state["unit"] = BillingUnitImage + state["updated_at"] = util.NowISO() + after := publicBillingState(state) + adjustment := s.addAdjustmentLocked(userID, operator, adjustmentType, amount, reason, before, after) + if err := s.saveLocked(); err != nil { + return nil, err + } + return map[string]any{"billing": after, "adjustment": adjustment}, nil +} + +func (s *BillingService) ApplyBulkAdjustment(userIDs []string, operator Identity, body map[string]any) ([]BillingBulkAdjustmentResult, error) { + if s == nil { + return nil, errors.New("billing service is unavailable") + } + ids := uniqueBillingUserIDs(userIDs) + if len(ids) == 0 { + return nil, errors.New("user ids are required") + } + if len(ids) > 500 { + return nil, errors.New("cannot adjust more than 500 users at once") + } + adjustmentType := strings.TrimSpace(util.Clean(body["type"])) + if adjustmentType == "" { + return nil, errors.New("adjustment type is required") + } + if !isSupportedBillingAdjustmentType(adjustmentType) { + return nil, fmt.Errorf("unsupported billing adjustment type: %s", adjustmentType) + } + amount := adjustmentAmount(body) + if billingAdjustmentNeedsPositiveAmount(adjustmentType) && amount <= 0 { + return nil, errors.New("amount must be greater than 0") + } + reason := strings.TrimSpace(util.Clean(body["reason"])) + + results := make([]BillingBulkAdjustmentResult, 0, len(ids)) + changed := false + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + for _, userID := range ids { + result := BillingBulkAdjustmentResult{UserID: userID} + state, _ := s.ensureStateLocked(userID) + s.resetSubscriptionIfDueLocked(state, now) + before := publicBillingState(state) + if err := s.applyAdjustmentLocked(state, adjustmentType, amount, body, now); err != nil { + result.Error = err.Error() + result.Billing = publicBillingState(state) + results = append(results, result) + continue + } + state["billing_type"] = normalizeBillingType(util.Clean(state["billing_type"])) + state["unit"] = BillingUnitImage + state["updated_at"] = util.NowISO() + after := publicBillingState(state) + adjustment := s.addAdjustmentLocked(userID, operator, adjustmentType, amount, reason, before, after) + result.Billing = after + result.Adjustment = adjustment + results = append(results, result) + changed = true + } + if changed { + if err := s.saveLocked(); err != nil { + return results, err + } + } + return results, nil +} + +func (s *BillingService) applyAdjustmentLocked(state map[string]any, adjustmentType string, amount int, body map[string]any, now time.Time) error { + switch adjustmentType { + case "set_unlimited": + state["unlimited"] = util.ToBool(body["unlimited"]) + case "switch_to_standard": + state["billing_type"] = BillingTypeStandard + if _, ok := body["balance"]; ok { + if err := setStandardBalance(state, util.ToInt(body["balance"], 0)); err != nil { + return err + } + } else if _, ok := body["amount"]; ok { + if err := setStandardBalance(state, amount); err != nil { + return err + } + } + case "switch_to_subscription": + rawQuotaLimit, ok := body["quota_limit"] + if !ok { + return errors.New("quota limit is required") + } + quotaLimit := util.ToInt(rawQuotaLimit, 0) + if quotaLimit < 0 { + return errors.New("quota limit cannot be negative") + } + period := normalizeBillingPeriod(util.Clean(body["quota_period"])) + if period == "" { + return errors.New("quota period must be daily, weekly, or monthly") + } + state["billing_type"] = BillingTypeSubscription + subscription := billingSubscriptionState(state) + subscription["quota_limit"] = quotaLimit + subscription["quota_period"] = period + resetSubscriptionPeriod(subscription, now) + case "set_balance": + if err := setStandardBalance(state, firstIntValue(body, "balance", "amount")); err != nil { + return err + } + case "increase_balance": + if amount <= 0 { + return errors.New("amount must be greater than 0") + } + standard := billingStandardState(state) + standard["balance"] = intField(standard, "balance") + amount + case "decrease_balance": + if amount <= 0 { + return errors.New("amount must be greater than 0") + } + standard := billingStandardState(state) + if intField(standard, "balance")-amount < 0 { + return errors.New("balance cannot be negative") + } + standard["balance"] = intField(standard, "balance") - amount + case "set_quota_limit": + limit := firstIntValue(body, "quota_limit", "amount") + if limit < 0 { + return errors.New("quota limit cannot be negative") + } + billingSubscriptionState(state)["quota_limit"] = limit + case "set_quota_period": + period := normalizeBillingPeriod(util.Clean(body["quota_period"])) + if period == "" { + return errors.New("quota period must be daily, weekly, or monthly") + } + subscription := billingSubscriptionState(state) + subscription["quota_period"] = period + resetSubscriptionPeriod(subscription, now) + case "reset_quota": + resetSubscriptionPeriod(billingSubscriptionState(state), now) + case "clear_quota_used": + billingSubscriptionState(state)["quota_used"] = 0 + case "increase_quota": + if amount <= 0 { + return errors.New("amount must be greater than 0") + } + subscription := billingSubscriptionState(state) + subscription["manual_delta"] = intField(subscription, "manual_delta") + amount + case "decrease_quota": + if amount <= 0 { + return errors.New("amount must be greater than 0") + } + subscription := billingSubscriptionState(state) + if availableSubscriptionQuota(subscription) < amount { + return errors.New("quota decrease cannot exceed remaining quota") + } + subscription["manual_delta"] = intField(subscription, "manual_delta") - amount + default: + return fmt.Errorf("unsupported billing adjustment type: %s", adjustmentType) + } + return nil +} + +func (s *BillingService) addAdjustmentLocked(userID string, operator Identity, adjustmentType string, amount int, reason string, before, after map[string]any) map[string]any { + adjustment := map[string]any{ + "id": "billing_adj_" + util.NewHex(18), + "user_id": userID, + "operator_id": billingUserID(operator), + "operator_name": operator.Name, + "billing_type": after["type"], + "type": adjustmentType, + "amount": amount, + "reason": reason, + "before": before, + "after": after, + "created_at": util.NowISO(), + } + s.adjustments = append(s.adjustments, adjustment) + s.addTransactionLocked(map[string]any{ + "user_id": userID, + "billing_type": after["type"], + "unit": BillingUnitImage, + "action": "adjust", + "adjustment_id": adjustment["id"], + "adjustment": adjustmentType, + "amount": amount, + }) + return adjustment +} + +func (s *BillingService) ListAdjustments(userID string, limit int) []map[string]any { + userID = strings.TrimSpace(userID) + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]map[string]any, 0, min(limit, len(s.adjustments))) + for i := len(s.adjustments) - 1; i >= 0 && len(out) < limit; i-- { + item := s.adjustments[i] + if userID != "" && util.Clean(item["user_id"]) != userID { + continue + } + out = append(out, copyBillingMap(item)) + } + return out +} + +func (s *BillingService) ensureStateLocked(userID string) (map[string]any, bool) { + if s.states == nil { + s.states = map[string]map[string]any{} + } + state := s.states[userID] + if state == nil { + state = legacyBillingState(userID) + s.states[userID] = state + return state, true + } + changed := normalizeBillingState(state, userID, nil) + return state, changed +} + +func (s *BillingService) stateForReadLocked(userID string) (map[string]any, bool) { + if s.states == nil { + return nil, false + } + state := s.states[userID] + if state == nil { + return nil, false + } + return state, true +} + +func (s *BillingService) resetSubscriptionIfDueLocked(state map[string]any, now time.Time) bool { + if normalizeBillingType(util.Clean(state["billing_type"])) != BillingTypeSubscription { + return false + } + subscription := billingSubscriptionState(state) + endsAt := parseBillingTime(util.Clean(subscription["quota_period_ends_at"])) + if !endsAt.IsZero() && now.Before(endsAt) { + return false + } + resetSubscriptionPeriod(subscription, now) + state["updated_at"] = util.NowISO() + s.addTransactionLocked(map[string]any{ + "user_id": util.Clean(state["user_id"]), + "billing_type": BillingTypeSubscription, + "unit": BillingUnitImage, + "action": "reset_subscription_period", + }) + return true +} + +func (s *BillingService) loadLocked() { + raw := loadStoredJSON(s.store, billingDocumentName) + doc, _ := raw.(map[string]any) + s.states = map[string]map[string]any{} + if states, ok := doc["states"].(map[string]any); ok { + for userID, value := range states { + if state, ok := value.(map[string]any); ok { + normalizeBillingState(state, userID, nil) + s.states[userID] = state + } + } + } + s.adjustments = util.AsMapSlice(doc["adjustments"]) + s.transactions = util.AsMapSlice(doc["transactions"]) + if s.adjustments == nil { + s.adjustments = []map[string]any{} + } + if s.transactions == nil { + s.transactions = []map[string]any{} + } +} + +func (s *BillingService) saveLocked() error { + states := map[string]any{} + keys := make([]string, 0, len(s.states)) + for key := range s.states { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + states[key] = s.states[key] + } + doc := map[string]any{ + "states": states, + "adjustments": s.adjustments, + "transactions": s.transactions, + "updated_at": util.NowISO(), + } + return saveStoredJSON(s.store, billingDocumentName, doc) +} + +func (s *BillingService) addTransactionLocked(item map[string]any) { + if item == nil { + return + } + item = copyBillingMap(item) + if util.Clean(item["id"]) == "" { + item["id"] = "billing_txn_" + util.NewHex(18) + } + if util.Clean(item["created_at"]) == "" { + item["created_at"] = util.NowISO() + } + s.transactions = append(s.transactions, item) + if len(s.transactions) > 5000 { + s.transactions = append([]map[string]any(nil), s.transactions[len(s.transactions)-5000:]...) + } +} + +func (s *BillingService) hasChargeKeyLocked(userID, chargeKey string) bool { + userID = strings.TrimSpace(userID) + chargeKey = strings.TrimSpace(chargeKey) + if userID == "" || chargeKey == "" { + return false + } + for i := len(s.transactions) - 1; i >= 0; i-- { + if util.Clean(s.transactions[i]["user_id"]) == userID && util.Clean(s.transactions[i]["charge_key"]) == chargeKey { + return true + } + } + return false +} + +func (s *BillingService) hasRefundKeyLocked(userID, refundKey string) bool { + userID = strings.TrimSpace(userID) + refundKey = strings.TrimSpace(refundKey) + if userID == "" || refundKey == "" { + return false + } + for i := len(s.transactions) - 1; i >= 0; i-- { + if util.Clean(s.transactions[i]["user_id"]) == userID && util.Clean(s.transactions[i]["charge_key"]) == refundKey && util.Clean(s.transactions[i]["action"]) == "refund" { + return true + } + } + return false +} + +func (s *BillingService) refundableAmountLocked(userID string, amount int, chargeKey string) int { + amount = max(0, amount) + chargeKey = strings.TrimSpace(chargeKey) + if amount <= 0 || chargeKey == "" { + return amount + } + charged := 0 + refunded := 0 + for _, txn := range s.transactions { + if util.Clean(txn["user_id"]) != userID { + continue + } + switch util.Clean(txn["action"]) { + case "charge": + if util.Clean(txn["charge_key"]) == chargeKey { + charged += util.ToInt(txn["consumed_amount"], 0) + } + case "refund": + if util.Clean(txn["refund_for_charge_key"]) == chargeKey { + refunded += util.ToInt(txn["refunded_amount"], 0) + } + } + } + return min(amount, max(0, charged-refunded)) +} + +func defaultBillingState(userID string, defaults BillingDefaults) map[string]any { + now := time.Now() + period := defaultBillingPeriod(defaults) + started, ends := billingPeriodBounds(period, now) + return map[string]any{ + "user_id": userID, + "billing_type": defaultBillingType(defaults), + "unit": BillingUnitImage, + "unlimited": false, + "standard": map[string]any{ + "balance": max(0, defaultStandardBalance(defaults)), + "lifetime_consumed": 0, + }, + "subscription": map[string]any{ + "quota_limit": max(0, defaultSubscriptionQuota(defaults)), + "quota_used": 0, + "manual_delta": 0, + "quota_period": period, + "quota_period_started_at": started.Format(time.RFC3339), + "quota_period_ends_at": ends.Format(time.RFC3339), + }, + "updated_at": util.NowISO(), + } +} + +func legacyBillingState(userID string) map[string]any { + return defaultBillingState(userID, nil) +} + +func normalizeBillingState(state map[string]any, userID string, defaults BillingDefaults) bool { + changed := false + if util.Clean(state["user_id"]) != userID { + state["user_id"] = userID + changed = true + } + billingType := normalizeBillingType(util.Clean(state["billing_type"])) + if billingType == "" { + billingType = defaultBillingType(defaults) + } + if state["billing_type"] != billingType { + state["billing_type"] = billingType + changed = true + } + if state["unit"] != BillingUnitImage { + state["unit"] = BillingUnitImage + changed = true + } + if _, ok := state["unlimited"]; !ok { + state["unlimited"] = false + changed = true + } + if _, ok := state["standard"].(map[string]any); !ok { + state["standard"] = map[string]any{ + "balance": max(0, defaultStandardBalance(defaults)), + "lifetime_consumed": 0, + } + changed = true + } + standard := billingStandardState(state) + for key := range map[string]struct{}{"balance": {}, "lifetime_consumed": {}} { + value := max(0, intField(standard, key)) + if standard[key] != value { + standard[key] = value + changed = true + } + } + if _, ok := standard["balance_reserved"]; ok { + delete(standard, "balance_reserved") + changed = true + } + if _, ok := state["subscription"].(map[string]any); !ok { + period := defaultBillingPeriod(defaults) + started, ends := billingPeriodBounds(period, time.Now()) + state["subscription"] = map[string]any{ + "quota_limit": max(0, defaultSubscriptionQuota(defaults)), + "quota_used": 0, + "manual_delta": 0, + "quota_period": period, + "quota_period_started_at": started.Format(time.RFC3339), + "quota_period_ends_at": ends.Format(time.RFC3339), + } + changed = true + } + subscription := billingSubscriptionState(state) + for key := range map[string]struct{}{"quota_limit": {}, "quota_used": {}} { + value := max(0, intField(subscription, key)) + if subscription[key] != value { + subscription[key] = value + changed = true + } + } + if manualDelta := intField(subscription, "manual_delta"); subscription["manual_delta"] != manualDelta { + subscription["manual_delta"] = manualDelta + changed = true + } + if _, ok := subscription["quota_reserved"]; ok { + delete(subscription, "quota_reserved") + changed = true + } + period := normalizeBillingPeriod(util.Clean(subscription["quota_period"])) + if period == "" { + period = defaultBillingPeriod(defaults) + } + if subscription["quota_period"] != period { + subscription["quota_period"] = period + changed = true + } + if parseBillingTime(util.Clean(subscription["quota_period_started_at"])).IsZero() || parseBillingTime(util.Clean(subscription["quota_period_ends_at"])).IsZero() { + resetSubscriptionPeriod(subscription, time.Now()) + changed = true + } + if util.Clean(state["updated_at"]) == "" { + state["updated_at"] = util.NowISO() + changed = true + } + return changed +} + +func publicBillingState(state map[string]any) map[string]any { + billingType := normalizeBillingType(util.Clean(state["billing_type"])) + unlimited := util.ToBool(state["unlimited"]) + out := map[string]any{ + "type": billingType, + "unit": BillingUnitImage, + "unlimited": unlimited, + "available": 0, + "standard": nil, + "subscription": nil, + "updated_at": state["updated_at"], + } + switch billingType { + case BillingTypeStandard: + standard := copyBillingMap(billingStandardState(state)) + available := availableStandardBalance(standard) + out["available"] = available + standard["available_balance"] = available + out["standard"] = standard + case BillingTypeSubscription: + subscription := copyBillingMap(billingSubscriptionState(state)) + available := availableSubscriptionQuota(subscription) + out["available"] = available + subscription["remaining_quota"] = available + out["subscription"] = subscription + } + if unlimited { + out["limit_state"] = "unlimited" + } else if util.ToInt(out["available"], 0) > 0 { + out["limit_state"] = "ok" + } else { + out["limit_state"] = "insufficient" + } + return out +} + +func billingStandardState(state map[string]any) map[string]any { + standard, ok := state["standard"].(map[string]any) + if !ok || standard == nil { + standard = map[string]any{} + state["standard"] = standard + } + return standard +} + +func billingSubscriptionState(state map[string]any) map[string]any { + subscription, ok := state["subscription"].(map[string]any) + if !ok || subscription == nil { + subscription = map[string]any{} + state["subscription"] = subscription + } + return subscription +} + +func availableStandardBalance(standard map[string]any) int { + return max(0, intField(standard, "balance")) +} + +func availableSubscriptionQuota(subscription map[string]any) int { + return max(0, intField(subscription, "quota_limit")+intField(subscription, "manual_delta")-intField(subscription, "quota_used")) +} + +func setStandardBalance(state map[string]any, balance int) error { + if balance < 0 { + return errors.New("balance cannot be negative") + } + standard := billingStandardState(state) + standard["balance"] = balance + return nil +} + +func resetSubscriptionPeriod(subscription map[string]any, now time.Time) { + period := normalizeBillingPeriod(util.Clean(subscription["quota_period"])) + if period == "" { + period = BillingPeriodMonthly + } + started, ends := billingPeriodBounds(period, now) + subscription["quota_used"] = 0 + subscription["manual_delta"] = 0 + subscription["quota_period"] = period + subscription["quota_period_started_at"] = started.Format(time.RFC3339) + subscription["quota_period_ends_at"] = ends.Format(time.RFC3339) +} + +func billingPeriodBounds(period string, now time.Time) (time.Time, time.Time) { + loc := now.Location() + switch normalizeBillingPeriod(period) { + case BillingPeriodDaily: + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + return start, start.AddDate(0, 0, 1) + case BillingPeriodWeekly: + weekdayOffset := (int(now.Weekday()) + 6) % 7 + day := now.AddDate(0, 0, -weekdayOffset) + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + return start, start.AddDate(0, 0, 7) + default: + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + return start, start.AddDate(0, 1, 0) + } +} + +func parseBillingTime(value string) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, value); err == nil { + return t + } + } + return time.Time{} +} + +func billingUserID(identity Identity) string { + if owner := util.Clean(identity.OwnerID); owner != "" { + return owner + } + return util.Clean(identity.ID) +} + +func normalizeBillingType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case BillingTypeSubscription: + return BillingTypeSubscription + case "", BillingTypeStandard: + return BillingTypeStandard + default: + return "" + } +} + +func normalizeBillingPeriod(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case BillingPeriodDaily, BillingPeriodWeekly, BillingPeriodMonthly: + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +func defaultBillingType(defaults BillingDefaults) string { + if defaults == nil { + return BillingTypeStandard + } + if value := normalizeBillingType(defaults.DefaultBillingType()); value != "" { + return value + } + return BillingTypeStandard +} + +func defaultBillingPeriod(defaults BillingDefaults) string { + if defaults == nil { + return BillingPeriodMonthly + } + if value := normalizeBillingPeriod(defaults.DefaultSubscriptionPeriod()); value != "" { + return value + } + return BillingPeriodMonthly +} + +func defaultStandardBalance(defaults BillingDefaults) int { + if defaults == nil { + return 0 + } + return defaults.DefaultStandardBalance() +} + +func defaultSubscriptionQuota(defaults BillingDefaults) int { + if defaults == nil { + return 0 + } + return defaults.DefaultSubscriptionQuota() +} + +func intField(item map[string]any, key string) int { + return util.ToInt(item[key], 0) +} + +func firstIntValue(item map[string]any, keys ...string) int { + for _, key := range keys { + if value, ok := item[key]; ok { + return util.ToInt(value, 0) + } + } + return 0 +} + +func adjustmentAmount(item map[string]any) int { + return firstIntValue(item, "amount", "balance", "quota_limit") +} + +func billingAdjustmentNeedsPositiveAmount(adjustmentType string) bool { + switch adjustmentType { + case "increase_balance", "decrease_balance", "increase_quota", "decrease_quota": + return true + default: + return false + } +} + +func isSupportedBillingAdjustmentType(adjustmentType string) bool { + switch adjustmentType { + case "set_unlimited", + "switch_to_standard", + "switch_to_subscription", + "set_balance", + "increase_balance", + "decrease_balance", + "set_quota_limit", + "set_quota_period", + "reset_quota", + "clear_quota_used", + "increase_quota", + "decrease_quota": + return true + default: + return false + } +} + +func uniqueBillingUserIDs(userIDs []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(userIDs)) + for _, userID := range userIDs { + userID = strings.TrimSpace(userID) + if userID == "" { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + } + return out +} + +func copyBillingMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for key, value := range in { + if child, ok := value.(map[string]any); ok { + out[key] = copyBillingMap(child) + } else { + out[key] = value + } + } + return out +} diff --git a/internal/service/billing_test.go b/internal/service/billing_test.go new file mode 100644 index 000000000..02131dc98 --- /dev/null +++ b/internal/service/billing_test.go @@ -0,0 +1,606 @@ +package service + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "chatgpt2api/internal/util" +) + +type testBillingDefaults struct { + billingType string + standardBalance int + subscriptionQuota int + subscriptionPeriod string +} + +func (d testBillingDefaults) DefaultBillingType() string { + return d.billingType +} + +func (d testBillingDefaults) DefaultStandardBalance() int { + return d.standardBalance +} + +func (d testBillingDefaults) DefaultSubscriptionQuota() int { + return d.subscriptionQuota +} + +func (d testBillingDefaults) DefaultSubscriptionPeriod() string { + return d.subscriptionPeriod +} + +func newTestBillingService(t *testing.T, defaults testBillingDefaults) *BillingService { + t.Helper() + backend := newTestStorageBackend(t) + svc := NewBillingService(backend, defaults) + svc.InitializeUserDefaults("alice") + svc.InitializeUserDefaults("bob") + return svc +} + +func newTestBillingServiceAt(t *testing.T, defaults testBillingDefaults) *BillingService { + t.Helper() + backend := newTestStorageBackend(t) + return NewBillingService(backend, defaults) +} + +func billingTestUser(id string) Identity { + return Identity{ID: id, Name: id, Role: AuthRoleUser, CredentialID: "cred-" + id} +} + +func TestBillingServiceDefaults(t *testing.T) { + standard := newTestBillingService(t, testBillingDefaults{}) + got := standard.Get("alice") + if got["type"] != BillingTypeStandard || util.ToInt(got["available"], -1) != 0 { + t.Fatalf("standard default = %#v", got) + } + + subscription := newTestBillingService(t, testBillingDefaults{ + billingType: BillingTypeSubscription, + subscriptionQuota: 12, + subscriptionPeriod: BillingPeriodWeekly, + }) + got = subscription.Get("bob") + if got["type"] != BillingTypeSubscription || util.ToInt(got["available"], 0) != 12 { + t.Fatalf("subscription default = %#v", got) + } + sub := util.StringMap(got["subscription"]) + if sub["quota_period"] != BillingPeriodWeekly { + t.Fatalf("quota_period = %#v in %#v", sub["quota_period"], got) + } +} + +func TestBillingServiceDefaultBoundaryNormalization(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{ + billingType: "unsupported", + standardBalance: -5, + }) + got := svc.Get("alice") + if got["type"] != BillingTypeStandard || util.ToInt(got["available"], -1) != 0 { + t.Fatalf("normalized default = %#v", got) + } + + svc = newTestBillingService(t, testBillingDefaults{ + billingType: BillingTypeSubscription, + subscriptionQuota: -7, + subscriptionPeriod: "yearly", + }) + got = svc.Get("bob") + sub := util.StringMap(got["subscription"]) + if got["type"] != BillingTypeSubscription || util.ToInt(got["available"], -1) != 0 || util.ToInt(sub["quota_limit"], -1) != 0 || sub["quota_period"] != BillingPeriodMonthly { + t.Fatalf("normalized subscription defaults = %#v", got) + } +} + +func TestBillingServiceApplyBulkAdjustment(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 4}) + results, err := svc.ApplyBulkAdjustment([]string{"alice", "bob", "alice"}, billingTestUser("admin"), map[string]any{ + "type": "increase_balance", + "amount": 3, + "reason": "promo", + }) + if err != nil { + t.Fatalf("ApplyBulkAdjustment() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("results len = %d, want 2: %#v", len(results), results) + } + for _, userID := range []string{"alice", "bob"} { + got := svc.Get(userID) + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 7 { + t.Fatalf("%s balance = %#v, want 7", userID, got) + } + } + if adjustments := svc.ListAdjustments("", 10); len(adjustments) != 2 { + t.Fatalf("adjustments len = %d, want 2: %#v", len(adjustments), adjustments) + } +} + +func TestBillingServiceApplyBulkAdjustmentReportsPerUserFailures(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 2}) + if _, err := svc.ApplyAdjustment("bob", billingTestUser("admin"), map[string]any{"type": "decrease_balance", "amount": 1}); err != nil { + t.Fatalf("ApplyAdjustment(bob) error = %v", err) + } + + results, err := svc.ApplyBulkAdjustment([]string{"alice", "bob"}, billingTestUser("admin"), map[string]any{ + "type": "decrease_balance", + "amount": 2, + }) + if err != nil { + t.Fatalf("ApplyBulkAdjustment() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("results len = %d, want 2: %#v", len(results), results) + } + if results[0].UserID != "alice" || results[0].Error != "" { + t.Fatalf("alice result = %#v", results[0]) + } + if results[1].UserID != "bob" || results[1].Error == "" { + t.Fatalf("bob result = %#v, want per-user error", results[1]) + } + if got := svc.Get("alice"); util.ToInt(util.StringMap(got["standard"])["balance"], -1) != 0 { + t.Fatalf("alice billing = %#v, want successful decrease", got) + } + if got := svc.Get("bob"); util.ToInt(util.StringMap(got["standard"])["balance"], -1) != 1 { + t.Fatalf("bob billing = %#v, want unchanged after failed decrease", got) + } +} + +func TestBillingServiceMissingStateDoesNotUseCurrentDefaults(t *testing.T) { + backend := newTestStorageBackend(t) + svc := NewBillingService(backend, testBillingDefaults{ + billingType: BillingTypeSubscription, + standardBalance: 7, + subscriptionQuota: 12, + subscriptionPeriod: BillingPeriodWeekly, + }) + + got := svc.Get("legacy-user") + if got["type"] != BillingTypeStandard || util.ToInt(got["available"], -1) != 0 { + t.Fatalf("missing user billing should not inherit current defaults = %#v", got) + } + + initialized := svc.InitializeUserDefaults("new-user") + subscription := util.StringMap(initialized["subscription"]) + if initialized["type"] != BillingTypeSubscription || util.ToInt(initialized["available"], -1) != 12 || subscription["quota_period"] != BillingPeriodWeekly { + t.Fatalf("initialized user billing should use current defaults = %#v", initialized) + } +} + +func TestBillingServiceCheckAndChargeStandard(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 4}) + user := billingTestUser("alice") + + if err := svc.CheckAvailable(user, 3); err != nil { + t.Fatalf("CheckAvailable() error = %v", err) + } + got := svc.Get("alice") + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], 0) != 4 || util.ToInt(got["available"], 0) != 4 { + t.Fatalf("after check = %#v", got) + } + + if err := svc.Charge(user, 2, BillingReference{Endpoint: "/v1/images/generations", Model: "gpt-image-2"}); err != nil { + t.Fatalf("Charge() error = %v", err) + } + got = svc.Get("alice") + standard = util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], 0) != 2 || util.ToInt(standard["lifetime_consumed"], 0) != 2 || util.ToInt(got["available"], 0) != 2 { + t.Fatalf("after charge = %#v", got) + } + + if err := svc.Charge(user, 0, BillingReference{}); err != nil { + t.Fatalf("Charge(0) error = %v", err) + } + got = svc.Get("alice") + standard = util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], 0) != 2 || util.ToInt(got["available"], 0) != 2 { + t.Fatalf("after zero charge = %#v", got) + } +} + +func TestBillingServiceCheckAvailableBoundaryClasses(t *testing.T) { + for _, tc := range []struct { + name string + amount int + wantErr bool + wantAvail int + }{ + {name: "zero is no-op", amount: 0, wantAvail: 2}, + {name: "negative is no-op", amount: -1, wantAvail: 2}, + {name: "below available", amount: 1, wantAvail: 2}, + {name: "exactly available", amount: 2, wantAvail: 2}, + {name: "one above available", amount: 3, wantErr: true, wantAvail: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 2}) + err := svc.CheckAvailable(billingTestUser("alice"), tc.amount) + if tc.wantErr { + var limitErr BillingLimitError + if !errors.As(err, &limitErr) || limitErr.Code != "user_balance_insufficient" { + t.Fatalf("CheckAvailable(%d) error = %#v", tc.amount, err) + } + return + } + if err != nil { + t.Fatalf("CheckAvailable(%d) error = %v", tc.amount, err) + } + got := svc.Get("alice") + if util.ToInt(got["available"], -1) != tc.wantAvail { + t.Fatalf("after CheckAvailable(%d) = %#v", tc.amount, got) + } + }) + } +} + +func TestBillingServiceChargeAllowsPartialActualConsumption(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 5}) + if err := svc.CheckAvailable(billingTestUser("alice"), 2); err != nil { + t.Fatalf("CheckAvailable() error = %v", err) + } + if err := svc.Charge(billingTestUser("alice"), 1, BillingReference{}); err != nil { + t.Fatalf("Charge() error = %v", err) + } + got := svc.Get("alice") + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 4 || util.ToInt(standard["lifetime_consumed"], -1) != 1 { + t.Fatalf("partial charge = %#v", got) + } + + if err := svc.Charge(billingTestUser("alice"), -5, BillingReference{}); err != nil { + t.Fatalf("Charge(negative) error = %v", err) + } + got = svc.Get("alice") + standard = util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 4 || util.ToInt(standard["lifetime_consumed"], -1) != 1 { + t.Fatalf("negative charge = %#v", got) + } +} + +func TestBillingServiceChargeIsAtomicAndIdempotent(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 1}) + user := billingTestUser("alice") + + result, err := svc.ChargeUserID("alice", 1, BillingReference{ChargeKey: "task:alice:one:0"}) + if err != nil || !result.Charged || result.AlreadyCharged { + t.Fatalf("first Charge() error = %v", err) + } + result, err = svc.ChargeUserID("alice", 1, BillingReference{ChargeKey: "task:alice:one:0"}) + if err != nil || !result.AlreadyCharged { + t.Fatalf("duplicate Charge() error = %v", err) + } + got := svc.Get("alice") + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 1 { + t.Fatalf("duplicate charge state = %#v", got) + } + + var limitErr BillingLimitError + if err := svc.Charge(user, 1, BillingReference{ChargeKey: "task:alice:one:1"}); !errors.As(err, &limitErr) || limitErr.Code != "user_balance_insufficient" { + t.Fatalf("insufficient Charge() error = %#v", err) + } + got = svc.Get("alice") + standard = util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 1 { + t.Fatalf("insufficient charge changed state = %#v", got) + } + + bob := billingTestUser("bob") + if err := svc.Charge(bob, 1, BillingReference{ChargeKey: "task:alice:one:0"}); err != nil { + t.Fatalf("same charge key for different user Charge() error = %v", err) + } + got = svc.Get("bob") + standard = util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 1 { + t.Fatalf("same charge key for different user state = %#v", got) + } +} + +func TestBillingServiceRefundsUnusedPrecharge(t *testing.T) { + t.Run("standard", func(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 4}) + user := billingTestUser("alice") + chargeKey := "task:alice:image:precharge" + if err := svc.Charge(user, 4, BillingReference{ChargeKey: chargeKey}); err != nil { + t.Fatalf("Charge() error = %v", err) + } + if _, err := svc.RefundUserID("alice", 2, BillingReference{ChargeKey: "task:alice:image:refund", RefundForKey: chargeKey}); err != nil { + t.Fatalf("RefundUserID() error = %v", err) + } + if _, err := svc.RefundUserID("alice", 2, BillingReference{ChargeKey: "task:alice:image:refund", RefundForKey: chargeKey}); err != nil { + t.Fatalf("duplicate RefundUserID() error = %v", err) + } + got := svc.Get("alice") + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 2 || util.ToInt(standard["lifetime_consumed"], -1) != 2 || util.ToInt(got["available"], -1) != 2 { + t.Fatalf("after standard refund = %#v", got) + } + }) + + t.Run("subscription", func(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{ + billingType: BillingTypeSubscription, + subscriptionQuota: 4, + subscriptionPeriod: BillingPeriodMonthly, + }) + user := billingTestUser("alice") + chargeKey := "task:alice:image:precharge" + if err := svc.Charge(user, 4, BillingReference{ChargeKey: chargeKey}); err != nil { + t.Fatalf("Charge() error = %v", err) + } + if _, err := svc.RefundUserID("alice", 3, BillingReference{ChargeKey: "task:alice:image:refund", RefundForKey: chargeKey}); err != nil { + t.Fatalf("RefundUserID() error = %v", err) + } + got := svc.Get("alice") + sub := util.StringMap(got["subscription"]) + if util.ToInt(sub["quota_used"], -1) != 1 || util.ToInt(got["available"], -1) != 3 { + t.Fatalf("after subscription refund = %#v", got) + } + }) +} + +func TestBillingServiceCheckAndChargeSubscription(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{ + billingType: BillingTypeSubscription, + subscriptionQuota: 4, + subscriptionPeriod: BillingPeriodMonthly, + }) + user := billingTestUser("alice") + + if err := svc.CheckAvailable(user, 3); err != nil { + t.Fatalf("CheckAvailable() error = %v", err) + } + got := svc.Get("alice") + sub := util.StringMap(got["subscription"]) + if util.ToInt(sub["quota_used"], 0) != 0 || util.ToInt(got["available"], 0) != 4 { + t.Fatalf("after check = %#v", got) + } + + if err := svc.Charge(user, 2, BillingReference{Endpoint: "/v1/images/generations"}); err != nil { + t.Fatalf("Charge() error = %v", err) + } + got = svc.Get("alice") + sub = util.StringMap(got["subscription"]) + if util.ToInt(sub["quota_used"], 0) != 2 || util.ToInt(got["available"], 0) != 2 { + t.Fatalf("after charge = %#v", got) + } + + if err := svc.Charge(user, 0, BillingReference{}); err != nil { + t.Fatalf("Charge(0) error = %v", err) + } + got = svc.Get("alice") + sub = util.StringMap(got["subscription"]) + if util.ToInt(sub["quota_used"], 0) != 2 || util.ToInt(got["available"], 0) != 2 { + t.Fatalf("after zero charge = %#v", got) + } +} + +func TestBillingServiceSubscriptionManualDeltaBoundaries(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{ + billingType: BillingTypeSubscription, + subscriptionQuota: 5, + subscriptionPeriod: BillingPeriodMonthly, + }) + operator := Identity{ID: "admin", Name: "Admin", Role: AuthRoleAdmin} + if _, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "increase_quota", "amount": 2, "reason": "bonus"}); err != nil { + t.Fatalf("increase_quota error = %v", err) + } + got := svc.Get("alice") + if util.ToInt(got["available"], -1) != 7 { + t.Fatalf("after increase_quota = %#v", got) + } + if _, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "decrease_quota", "amount": 7, "reason": "use up"}); err != nil { + t.Fatalf("decrease_quota exact remaining error = %v", err) + } + got = svc.Get("alice") + sub := util.StringMap(got["subscription"]) + if util.ToInt(got["available"], -1) != 0 || util.ToInt(sub["manual_delta"], 0) != -5 { + t.Fatalf("after exact decrease_quota = %#v", got) + } + if _, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "decrease_quota", "amount": 1, "reason": "too much"}); err == nil { + t.Fatal("decrease_quota beyond remaining error = nil") + } +} + +func TestBillingServiceInsufficientErrors(t *testing.T) { + standard := newTestBillingService(t, testBillingDefaults{standardBalance: 1}) + err := standard.CheckAvailable(billingTestUser("alice"), 2) + var limitErr BillingLimitError + if !errors.As(err, &limitErr) || limitErr.Code != "user_balance_insufficient" || limitErr.Message != "user balance insufficient" { + t.Fatalf("standard insufficient error = %#v", err) + } + + subscription := newTestBillingService(t, testBillingDefaults{ + billingType: BillingTypeSubscription, + subscriptionQuota: 1, + subscriptionPeriod: BillingPeriodDaily, + }) + err = subscription.CheckAvailable(billingTestUser("bob"), 2) + if !errors.As(err, &limitErr) || limitErr.Code != "user_quota_exceeded" || limitErr.Message != "user quota exceeded" { + t.Fatalf("subscription insufficient error = %#v", err) + } +} + +func TestBillingServiceAdjustmentValidationBoundaries(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 3}) + operator := Identity{ID: "admin", Name: "Admin", Role: AuthRoleAdmin} + for _, body := range []map[string]any{ + {"reason": "missing type"}, + {"type": "increase_balance", "amount": 0, "reason": "zero"}, + {"type": "decrease_balance", "amount": 4, "reason": "negative balance"}, + {"type": "switch_to_subscription", "quota_limit": 1, "reason": "missing period"}, + {"type": "switch_to_subscription", "quota_period": BillingPeriodMonthly, "reason": "missing limit"}, + {"type": "switch_to_subscription", "quota_limit": -1, "quota_period": BillingPeriodMonthly, "reason": "negative limit"}, + {"type": "set_quota_period", "quota_period": "yearly", "reason": "bad period"}, + } { + if _, err := svc.ApplyAdjustment("alice", operator, body); err == nil { + t.Fatalf("ApplyAdjustment(%#v) error = nil", body) + } + } + result, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "increase_balance", "amount": 1}) + if err != nil { + t.Fatalf("ApplyAdjustment() without reason error = %v", err) + } + adjustment := util.StringMap(result["adjustment"]) + if util.Clean(adjustment["reason"]) != "" { + t.Fatalf("adjustment reason = %#v, want empty", adjustment["reason"]) + } +} + +func TestBillingServiceAdminAndUnlimitedBypass(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{}) + admin := Identity{ID: "admin", Role: AuthRoleAdmin} + if err := svc.CheckAvailable(admin, 99); err != nil { + t.Fatalf("admin check = %v", err) + } + + operator := Identity{ID: "admin", Name: "Admin", Role: AuthRoleAdmin} + if _, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "set_unlimited", "unlimited": true, "reason": "test"}); err != nil { + t.Fatalf("set_unlimited error = %v", err) + } + if err := svc.CheckAvailable(billingTestUser("alice"), 99); err != nil { + t.Fatalf("unlimited check = %v", err) + } +} + +func TestBillingServiceOwnerIDScopesBillingState(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 2}) + svc.InitializeUserDefaults("linuxdo:123") + oldKey := Identity{ID: "key-old", OwnerID: "linuxdo:123", Name: "Alice", Role: AuthRoleUser} + newKey := Identity{ID: "key-new", OwnerID: "linuxdo:123", Name: "Alice", Role: AuthRoleUser} + if err := svc.CheckAvailable(oldKey, 2); err != nil { + t.Fatalf("CheckAvailable(old key) error = %v", err) + } + if err := svc.Charge(oldKey, 1, BillingReference{}); err != nil { + t.Fatalf("Charge(old key) error = %v", err) + } + if err := svc.CheckAvailable(newKey, 2); err == nil { + t.Fatal("CheckAvailable(new key same owner) error = nil, want shared owner balance checked") + } + got := svc.Get("linuxdo:123") + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 1 || util.ToInt(got["available"], -1) != 1 { + t.Fatalf("owner scoped billing = %#v", got) + } +} + +func TestBillingServiceSubscriptionResetAndAdjustments(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 5}) + operator := Identity{ID: "admin", Name: "Admin", Role: AuthRoleAdmin} + if _, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "switch_to_subscription", "quota_limit": 10, "quota_period": BillingPeriodDaily, "reason": "switch"}); err != nil { + t.Fatalf("switch_to_subscription error = %v", err) + } + if _, err := svc.ApplyAdjustment("alice", operator, map[string]any{"type": "increase_quota", "amount": 3, "reason": "bonus"}); err != nil { + t.Fatalf("increase_quota error = %v", err) + } + if err := svc.CheckAvailable(billingTestUser("alice"), 4); err != nil { + t.Fatalf("CheckAvailable() error = %v", err) + } + if err := svc.Charge(billingTestUser("alice"), 4, BillingReference{}); err != nil { + t.Fatalf("Charge() error = %v", err) + } + got := svc.Get("alice") + sub := util.StringMap(got["subscription"]) + if util.ToInt(sub["quota_used"], 0) != 4 || util.ToInt(sub["manual_delta"], 0) != 3 { + t.Fatalf("before reset = %#v", got) + } + + svc.mu.Lock() + state := svc.states["alice"] + sub = billingSubscriptionState(state) + sub["quota_period_ends_at"] = time.Now().Add(-time.Hour).Format(time.RFC3339) + _ = svc.saveLocked() + svc.mu.Unlock() + + got = svc.Get("alice") + sub = util.StringMap(got["subscription"]) + if util.ToInt(sub["quota_used"], -1) != 0 || util.ToInt(sub["manual_delta"], -1) != 0 || util.ToInt(sub["quota_limit"], 0) != 10 { + t.Fatalf("after reset = %#v", got) + } + if len(svc.ListAdjustments("alice", 10)) < 2 { + t.Fatalf("adjustments missing") + } +} + +func TestBillingServiceSubscriptionPeriodBounds(t *testing.T) { + loc := time.FixedZone("UTC+8", 8*60*60) + now := time.Date(2026, 5, 11, 15, 4, 5, 0, loc) + tests := []struct { + period string + start time.Time + end time.Time + }{ + { + period: BillingPeriodDaily, + start: time.Date(2026, 5, 11, 0, 0, 0, 0, loc), + end: time.Date(2026, 5, 12, 0, 0, 0, 0, loc), + }, + { + period: BillingPeriodWeekly, + start: time.Date(2026, 5, 11, 0, 0, 0, 0, loc), + end: time.Date(2026, 5, 18, 0, 0, 0, 0, loc), + }, + { + period: BillingPeriodMonthly, + start: time.Date(2026, 5, 1, 0, 0, 0, 0, loc), + end: time.Date(2026, 6, 1, 0, 0, 0, 0, loc), + }, + } + for _, tc := range tests { + t.Run(tc.period, func(t *testing.T) { + start, end := billingPeriodBounds(tc.period, now) + if !start.Equal(tc.start) || !end.Equal(tc.end) { + t.Fatalf("bounds = %s - %s, want %s - %s", start, end, tc.start, tc.end) + } + }) + } +} + +func TestBillingServicePersistsCurrentMapShapeOnly(t *testing.T) { + svc := newTestBillingServiceAt(t, testBillingDefaults{}) + svc.InitializeUserDefaults("alice") + if _, err := svc.ApplyAdjustment("alice", billingTestUser("admin"), map[string]any{"type": "increase_balance", "amount": 3}); err != nil { + t.Fatalf("ApplyAdjustment() error = %v", err) + } + + raw := loadStoredJSON(svc.store, billingDocumentName) + doc, _ := raw.(map[string]any) + if _, ok := doc["states"].(map[string]any); !ok { + t.Fatalf("states should persist as map shape: %#v", doc["states"]) + } +} + +func TestBillingServiceConcurrentChargeDoesNotOversell(t *testing.T) { + svc := newTestBillingService(t, testBillingDefaults{standardBalance: 5}) + user := billingTestUser("alice") + var wg sync.WaitGroup + successes := make(chan struct{}, 10) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + if err := svc.Charge(user, 1, BillingReference{ChargeKey: fmt.Sprintf("task:alice:concurrent:%d", index)}); err == nil { + successes <- struct{}{} + } + }(i) + } + wg.Wait() + close(successes) + count := 0 + for range successes { + count++ + } + if count != 5 { + t.Fatalf("successful charges = %d, want 5", count) + } + got := svc.Get("alice") + standard := util.StringMap(got["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 5 || util.ToInt(got["available"], -1) != 0 { + t.Fatalf("after concurrent charge = %#v", got) + } +} diff --git a/internal/service/cpa.go b/internal/service/cpa.go index 75139b0c0..35a1ec025 100644 --- a/internal/service/cpa.go +++ b/internal/service/cpa.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "path/filepath" "strings" "sync" "time" @@ -16,7 +15,6 @@ import ( type CPAConfig struct { mu sync.Mutex - path string store storage.JSONDocumentBackend pools []map[string]any docName string @@ -28,8 +26,8 @@ type CPAImportService struct { proxy *ProxyService } -func NewCPAConfig(dataDir string, backend ...storage.Backend) *CPAConfig { - c := &CPAConfig{path: filepath.Join(dataDir, "cpa_config.json"), store: firstJSONDocumentStore(backend), docName: "cpa_config.json"} +func NewCPAConfig(backend ...storage.Backend) *CPAConfig { + c := &CPAConfig{store: firstJSONDocumentStore(backend), docName: "cpa_config.json"} c.pools = c.load() return c } @@ -128,7 +126,7 @@ func (c *CPAConfig) GetImportJob(id string) map[string]any { } func (c *CPAConfig) load() []map[string]any { - raw := loadStoredJSON(c.store, c.docName, c.path) + raw := loadStoredJSON(c.store, c.docName) if obj, ok := raw.(map[string]any); ok && obj["base_url"] != nil { pool := normalizeCPAPool(obj) if util.Clean(pool["base_url"]) != "" { @@ -146,7 +144,7 @@ func (c *CPAConfig) load() []map[string]any { } func (c *CPAConfig) saveLocked() error { - return saveStoredJSON(c.store, c.docName, c.path, c.pools) + return saveStoredJSON(c.store, c.docName, c.pools) } func (s *CPAImportService) ListRemoteFiles(ctx context.Context, pool map[string]any) ([]map[string]any, error) { diff --git a/internal/service/image.go b/internal/service/image.go index 01d39e070..85aa5e56e 100644 --- a/internal/service/image.go +++ b/internal/service/image.go @@ -9,6 +9,7 @@ import ( _ "image/gif" "image/jpeg" _ "image/png" + "net/http" "net/url" "os" "path" @@ -27,6 +28,8 @@ const ( thumbnailQuality = 72 thumbnailCacheVersion = 3 thumbnailExtension = ".jpg" + imageReferencePrefix = "references" + imageReferenceMarker = ".refs/" ImageVisibilityPrivate = "private" ImageVisibilityPublic = "public" @@ -36,7 +39,8 @@ type ImageConfig interface { ImagesDir() string ImageThumbnailsDir() string ImageMetadataDir() string - CleanupOldImages() int + ImageRetentionDays() int + ImageStorageLimitBytes() int64 } type ImageAccessScope struct { @@ -46,19 +50,96 @@ type ImageAccessScope struct { } type imageMetadata struct { - OwnerID string - OwnerName string - Visibility string - PublishedAt string - ResolutionPreset string - RequestedSize string - OutputFormat string + OwnerID string + OwnerName string + Visibility string + PublishedAt string + Prompt string + Model string + Quality string + ResolutionPreset string + RequestedSize string + OutputFormat string + OutputCompression *int + Background string + Moderation string + Style string + PartialImages *int + InputImageMask string + ReferenceImages []imageReferenceMetadata + SharePromptParams bool + ShareReferences bool } type GeneratedImageMetadata struct { - ResolutionPreset string - RequestedSize string - OutputFormat string + Prompt string + Model string + Quality string + ResolutionPreset string + RequestedSize string + OutputFormat string + OutputCompression *int + Background string + Moderation string + Style string + PartialImages *int + InputImageMask string + ReferenceImages []GeneratedImageReference + SharePromptParams bool + ShareReferences bool +} + +type GeneratedImageReference struct { + Filename string + ContentType string + Data []byte +} + +type ImageStorageCleanupOptions struct { + RetentionDays int + MaxBytes int64 + ClearThumbnails bool + IncludePublic bool +} + +type ImageStorageGovernanceSummary struct { + TotalBytes int64 `json:"total_bytes"` + ImagesBytes int64 `json:"images_bytes"` + ThumbnailsBytes int64 `json:"thumbnails_bytes"` + MetadataBytes int64 `json:"metadata_bytes"` + ReferenceBytes int64 `json:"reference_bytes"` + ImagesCount int `json:"images_count"` + PublicImagesCount int `json:"public_images_count"` + PrivateImagesCount int `json:"private_images_count"` + ThumbnailFiles int `json:"thumbnail_files"` + MetadataFiles int `json:"metadata_files"` + ReferenceFiles int `json:"reference_files"` + LimitBytes int64 `json:"limit_bytes"` + OverLimitBytes int64 `json:"over_limit_bytes"` + OldestImageAt string `json:"oldest_image_at,omitempty"` + LatestImageAt string `json:"latest_image_at,omitempty"` +} + +type ImageStorageCleanupResult struct { + RetentionDays int `json:"retention_days,omitempty"` + MaxBytes int64 `json:"max_bytes,omitempty"` + IncludePublic bool `json:"include_public,omitempty"` + DeletedImages int `json:"deleted_images"` + DeletedThumbnails int `json:"deleted_thumbnails"` + DeletedMetadataFiles int `json:"deleted_metadata_files"` + DeletedReferenceFiles int `json:"deleted_reference_files"` + DeletedBytes int64 `json:"deleted_bytes"` + RemainingBytes int64 `json:"remaining_bytes"` + OverLimitBytes int64 `json:"over_limit_bytes"` + PreservedPublicImages int `json:"preserved_public_images,omitempty"` + Action string `json:"action,omitempty"` +} + +type imageReferenceMetadata struct { + Path string + Filename string + ContentType string + Size int64 } type ImageFileAccess struct { @@ -69,6 +150,21 @@ type ImageFileAccess struct { OwnerID string } +type ImageReferenceFileAccess struct { + Rel string + SourceRel string + Path string + ContentType string + Visibility string + OwnerID string + Shared bool +} + +type ImageVisibilityUpdateOptions struct { + SharePromptParams bool + ShareReferences bool +} + type ImageService struct { config ImageConfig store storage.JSONDocumentBackend @@ -87,12 +183,112 @@ type thumbnailJob struct { result map[string]any } +type imageCleanupCandidate struct { + rel string + path string + info os.FileInfo + meta imageMetadata + groupSize int64 +} + +type imageStorageRemovalStats struct { + bytes int64 + images int + thumbnails int + metadataFiles int + referenceFiles int +} + func NewImageService(config ImageConfig, backend ...storage.Backend) *ImageService { return &ImageService{config: config, store: firstJSONDocumentStore(backend)} } +func (s *ImageService) StorageGovernance() ImageStorageGovernanceSummary { + summary := ImageStorageGovernanceSummary{LimitBytes: s.config.ImageStorageLimitBytes()} + candidates := s.imageCleanupCandidates() + for _, candidate := range candidates { + summary.ImagesCount++ + summary.ImagesBytes += candidate.info.Size() + if candidate.meta.Visibility == ImageVisibilityPublic { + summary.PublicImagesCount++ + } else { + summary.PrivateImagesCount++ + } + created := candidate.info.ModTime().Format("2006-01-02 15:04:05") + if summary.OldestImageAt == "" || created < summary.OldestImageAt { + summary.OldestImageAt = created + } + if summary.LatestImageAt == "" || created > summary.LatestImageAt { + summary.LatestImageAt = created + } + } + summary.ThumbnailsBytes, summary.ThumbnailFiles, _ = thumbnailCacheStats(s.config.ImageThumbnailsDir()) + summary.MetadataBytes, summary.MetadataFiles = directorySize(s.config.ImageMetadataDir(), "") + summary.ReferenceBytes, summary.ReferenceFiles = directorySize(s.imageReferencesDir(), "") + summary.TotalBytes = summary.ImagesBytes + summary.ThumbnailsBytes + summary.MetadataBytes + if summary.LimitBytes > 0 && summary.TotalBytes > summary.LimitBytes { + summary.OverLimitBytes = summary.TotalBytes - summary.LimitBytes + } + return summary +} + +func (s *ImageService) CleanupStorage(options ImageStorageCleanupOptions) (ImageStorageCleanupResult, error) { + result := ImageStorageCleanupResult{ + RetentionDays: options.RetentionDays, + MaxBytes: options.MaxBytes, + IncludePublic: options.IncludePublic, + } + if options.ClearThumbnails { + stats, err := s.clearThumbnailCache() + if err != nil { + return result, err + } + result.Action = "thumbnails" + result.DeletedThumbnails += stats.thumbnails + result.DeletedMetadataFiles += stats.metadataFiles + result.DeletedBytes += stats.bytes + } + if options.RetentionDays > 0 { + stats, preserved, err := s.cleanupByRetention(options.RetentionDays, options.IncludePublic) + if err != nil { + return result, err + } + if result.Action == "" { + result.Action = "retention" + } + result.addRemovalStats(stats) + result.PreservedPublicImages += preserved + } + if options.MaxBytes > 0 { + stats, preserved, err := s.cleanupByStorageLimit(options.MaxBytes, options.IncludePublic) + if err != nil { + return result, err + } + if result.Action == "" { + result.Action = "quota" + } + result.addRemovalStats(stats) + result.PreservedPublicImages += preserved + } + summary := s.StorageGovernance() + result.RemainingBytes = summary.TotalBytes + result.OverLimitBytes = summary.OverLimitBytes + return result, nil +} + +func (r *ImageStorageCleanupResult) addRemovalStats(stats imageStorageRemovalStats) { + r.DeletedBytes += stats.bytes + r.DeletedImages += stats.images + r.DeletedThumbnails += stats.thumbnails + r.DeletedMetadataFiles += stats.metadataFiles + r.DeletedReferenceFiles += stats.referenceFiles +} + func (s *ImageService) ListImages(baseURL, startDate, endDate string, scope ImageAccessScope) map[string]any { - s.config.CleanupOldImages() + _, _ = s.CleanupStorage(ImageStorageCleanupOptions{ + RetentionDays: s.config.ImageRetentionDays(), + MaxBytes: s.config.ImageStorageLimitBytes(), + }) root := s.config.ImagesDir() items := make([]map[string]any, 0) _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { @@ -138,24 +334,11 @@ func (s *ImageService) ListImages(baseURL, startDate, endDate string, scope Imag "created_at": info.ModTime().Format("2006-01-02 15:04:05"), "visibility": meta.Visibility, } - if ownerID != "" { - item["owner_id"] = ownerID - } - if meta.OwnerName != "" { - item["owner_name"] = meta.OwnerName - } - if meta.PublishedAt != "" { - item["published_at"] = meta.PublishedAt - } - if meta.ResolutionPreset != "" { - item["resolution_preset"] = meta.ResolutionPreset - } - if meta.RequestedSize != "" { - item["requested_size"] = meta.RequestedSize - } - if meta.OutputFormat != "" { - item["output_format"] = meta.OutputFormat - } + addImageMetadataFields(item, meta, imageMetadataFieldOptions{ + BaseURL: baseURL, + IncludeReusableFields: !scope.Public || meta.SharePromptParams, + IncludeReferenceImages: !scope.Public || meta.ShareReferences, + }) if thumbRel, ok := thumb["thumbnail_rel"].(string); ok && thumbRel != "" { item["thumbnail_url"] = thumbnailURL(baseURL, thumbRel, info.ModTime()) } else { @@ -194,11 +377,18 @@ func (s *ImageService) ListImages(baseURL, startDate, endDate string, scope Imag return map[string]any{"items": items, "groups": groups} } -func (s *ImageService) UpdateImageVisibility(value, visibility string, scope ImageAccessScope) (map[string]any, error) { +func (s *ImageService) UpdateImageVisibility(value, visibility string, scope ImageAccessScope, optionValues ...ImageVisibilityUpdateOptions) (map[string]any, error) { visibility, err := NormalizeImageVisibility(visibility) if err != nil { return nil, err } + options := ImageVisibilityUpdateOptions{} + if len(optionValues) > 0 { + options = optionValues[0] + } + if visibility != ImageVisibilityPublic { + options = ImageVisibilityUpdateOptions{} + } rel, err := imageRelativePathFromValue(value) if err != nil { return nil, err @@ -218,7 +408,10 @@ func (s *ImageService) UpdateImageVisibility(value, visibility string, scope Ima if !scope.All && (scope.OwnerID == "" || meta.OwnerID != scope.OwnerID) { return nil, errors.New("image not found") } - if err := s.writeImageMetadataForRef(ref, "", "", visibility); err != nil { + if err := s.writeImageMetadataForRef(ref, "", "", visibility, GeneratedImageMetadata{ + SharePromptParams: options.SharePromptParams, + ShareReferences: options.ShareReferences, + }); err != nil { return nil, err } nextMeta := s.imageMetadata(ref.rel) @@ -230,21 +423,7 @@ func (s *ImageService) UpdateImageVisibility(value, visibility string, scope Ima "visibility": nextMeta.Visibility, "created_at": ref.info.ModTime().Format("2006-01-02 15:04:05"), } - if nextMeta.OwnerID != "" { - item["owner_id"] = nextMeta.OwnerID - } - if nextMeta.OwnerName != "" { - item["owner_name"] = nextMeta.OwnerName - } - if nextMeta.PublishedAt != "" { - item["published_at"] = nextMeta.PublishedAt - } - if nextMeta.ResolutionPreset != "" { - item["resolution_preset"] = nextMeta.ResolutionPreset - } - if nextMeta.RequestedSize != "" { - item["requested_size"] = nextMeta.RequestedSize - } + addImageMetadataFields(item, nextMeta) if width, height, ok := imageFileDimensions(ref.path); ok { setImageItemDimensions(item, width, height) } @@ -280,6 +459,71 @@ func (s *ImageService) ImageFileAccess(value string, scope ImageAccessScope) (Im }, nil } +func (s *ImageService) ImageReferenceFileAccess(value string) (ImageReferenceFileAccess, error) { + rel, err := imageReferenceRelativePathFromValue(value) + if err != nil { + return ImageReferenceFileAccess{}, err + } + sourceRel, err := sourceImageRelativePathFromReference(rel) + if err != nil { + return ImageReferenceFileAccess{}, err + } + meta := s.imageMetadata(sourceRel) + var metadata imageReferenceMetadata + for _, ref := range meta.ReferenceImages { + if ref.Path == rel { + metadata = ref + break + } + } + if metadata.Path == "" { + return ImageReferenceFileAccess{}, errors.New("image not found") + } + root, err := filepath.Abs(s.imageReferencesDir()) + if err != nil { + return ImageReferenceFileAccess{}, err + } + refPath := filepath.Join(root, filepath.FromSlash(rel)) + if !pathInsideRoot(root, refPath) { + return ImageReferenceFileAccess{}, errors.New("invalid image path") + } + info, err := os.Stat(refPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ImageReferenceFileAccess{}, errors.New("image not found") + } + return ImageReferenceFileAccess{}, err + } + if info.IsDir() { + return ImageReferenceFileAccess{}, errors.New("image not found") + } + return ImageReferenceFileAccess{ + Rel: rel, + SourceRel: sourceRel, + Path: refPath, + ContentType: metadata.ContentType, + Visibility: meta.Visibility, + OwnerID: meta.OwnerID, + Shared: meta.ShareReferences, + }, nil +} + +func (s *ImageService) ImageBytes(value string, scope ImageAccessScope) ([]byte, string, error) { + access, err := s.ImageFileAccess(value, scope) + if err != nil { + return nil, "", err + } + data, err := os.ReadFile(access.Path) + if err != nil { + return nil, "", err + } + mimeType := http.DetectContentType(data) + if !strings.HasPrefix(mimeType, "image/") { + return nil, "", errors.New("unsupported image file") + } + return data, mimeType, nil +} + func (s *ImageService) DeleteImages(paths []string, scope ImageAccessScope) (map[string]any, error) { if len(paths) == 0 { return nil, errors.New("paths is required") @@ -288,10 +532,6 @@ func (s *ImageService) DeleteImages(paths []string, scope ImageAccessScope) (map if err != nil { return nil, err } - thumbnailRoot, err := filepath.Abs(s.config.ImageThumbnailsDir()) - if err != nil { - return nil, err - } seen := make(map[string]struct{}, len(paths)) deleted := 0 @@ -307,38 +547,29 @@ func (s *ImageService) DeleteImages(paths []string, scope ImageAccessScope) (map } seen[rel] = struct{}{} - imagePath := filepath.Join(imageRoot, filepath.FromSlash(rel)) - if !pathInsideRoot(imageRoot, imagePath) { + if _, err := s.imageFileRef(imageRoot, rel); err != nil { + if errors.Is(err, os.ErrNotExist) { + missing++ + continue + } + return nil, err + } + if !pathInsideRoot(imageRoot, filepath.Join(imageRoot, filepath.FromSlash(rel))) { return nil, errors.New("invalid image path") } if !scope.All && (scope.OwnerID == "" || s.imageOwner(rel) != scope.OwnerID) { missing++ continue } - if err := s.removeImageThumbnail(thumbnailRoot, rel); err != nil { - return nil, err - } - if err := s.removeImageOwner(rel); err != nil { + stats, err := s.removeImageGroup(rel) + if err != nil { return nil, err } - info, err := os.Stat(imagePath) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return nil, err - } - missing++ - } else if info.IsDir() { - return nil, errors.New("image path is not a file") - } else if err := os.Remove(imagePath); err != nil { - if !errors.Is(err, os.ErrNotExist) { - return nil, err - } + if stats.images == 0 { missing++ } else { deleted++ } - - removeEmptyParentDirs(imageRoot, filepath.Dir(imagePath)) removedPaths = append(removedPaths, rel) } return map[string]any{"deleted": deleted, "missing": missing, "paths": removedPaths}, nil @@ -589,13 +820,25 @@ func normalizeImageMetadata(raw map[string]any) imageMetadata { visibility = ImageVisibilityPrivate } return imageMetadata{ - OwnerID: strings.TrimSpace(toString(raw["owner_id"])), - OwnerName: strings.TrimSpace(toString(raw["owner_name"])), - Visibility: visibility, - PublishedAt: strings.TrimSpace(toString(raw["published_at"])), - ResolutionPreset: NormalizeImageResolutionPreset(toString(raw["resolution_preset"])), - RequestedSize: strings.TrimSpace(toString(raw["requested_size"])), - OutputFormat: NormalizeImageOutputFormat(strings.TrimSpace(toString(raw["output_format"]))), + OwnerID: strings.TrimSpace(toString(raw["owner_id"])), + OwnerName: strings.TrimSpace(toString(raw["owner_name"])), + Visibility: visibility, + PublishedAt: strings.TrimSpace(toString(raw["published_at"])), + Prompt: strings.TrimSpace(toString(raw["prompt"])), + Model: strings.TrimSpace(toString(raw["model"])), + Quality: strings.TrimSpace(toString(raw["quality"])), + ResolutionPreset: NormalizeImageResolutionPreset(toString(raw["resolution_preset"])), + RequestedSize: strings.TrimSpace(toString(raw["requested_size"])), + OutputFormat: NormalizeImageOutputFormat(strings.TrimSpace(toString(raw["output_format"]))), + OutputCompression: imageOutputCompressionMetadata(raw["output_compression"]), + Background: strings.TrimSpace(toString(raw["background"])), + Moderation: strings.TrimSpace(toString(raw["moderation"])), + Style: strings.TrimSpace(toString(raw["style"])), + PartialImages: positiveImageMetadataInt(raw["partial_images"]), + InputImageMask: strings.TrimSpace(toString(raw["input_image_mask"])), + ReferenceImages: normalizeImageReferenceMetadata(raw["reference_images"]), + SharePromptParams: boolMetadataValue(raw["share_prompt_parameters"]), + ShareReferences: boolMetadataValue(raw["share_reference_images"]), } } @@ -623,6 +866,15 @@ func (s *ImageService) writeImageMetadataForRef(ref imageFileRef, ownerID, owner } if len(metadataValues) > 0 { metadata := metadataValues[0] + if prompt := strings.TrimSpace(metadata.Prompt); prompt != "" { + meta.Prompt = prompt + } + if model := strings.TrimSpace(metadata.Model); model != "" { + meta.Model = model + } + if quality := strings.TrimSpace(metadata.Quality); quality != "" { + meta.Quality = quality + } if preset := NormalizeImageResolutionPreset(metadata.ResolutionPreset); preset != "" { meta.ResolutionPreset = preset } @@ -632,6 +884,36 @@ func (s *ImageService) writeImageMetadataForRef(ref imageFileRef, ownerID, owner if outputFormat := NormalizeImageOutputFormat(metadata.OutputFormat); outputFormat != "" { meta.OutputFormat = outputFormat } + if metadata.OutputCompression != nil { + compression := *metadata.OutputCompression + if compression < 0 { + compression = 0 + } else if compression > 100 { + compression = 100 + } + meta.OutputCompression = &compression + } + if background := strings.TrimSpace(metadata.Background); background != "" { + meta.Background = background + } + if moderation := strings.TrimSpace(metadata.Moderation); moderation != "" { + meta.Moderation = moderation + } + if style := strings.TrimSpace(metadata.Style); style != "" { + meta.Style = style + } + if metadata.PartialImages != nil && *metadata.PartialImages > 0 { + partialImages := *metadata.PartialImages + meta.PartialImages = &partialImages + } + if inputImageMask := strings.TrimSpace(metadata.InputImageMask); inputImageMask != "" { + meta.InputImageMask = inputImageMask + } + if len(metadata.ReferenceImages) > 0 { + meta.ReferenceImages = s.writeImageReferencesForRef(ref, metadata.ReferenceImages) + } + meta.SharePromptParams = metadata.SharePromptParams + meta.ShareReferences = metadata.ShareReferences } if meta.Visibility == "" { meta.Visibility = ImageVisibilityPrivate @@ -657,6 +939,15 @@ func (s *ImageService) writeImageMetadata(rel string, meta imageMetadata) error if meta.PublishedAt != "" { value["published_at"] = meta.PublishedAt } + if meta.Prompt != "" { + value["prompt"] = meta.Prompt + } + if meta.Model != "" { + value["model"] = meta.Model + } + if meta.Quality != "" { + value["quality"] = meta.Quality + } if meta.ResolutionPreset != "" { value["resolution_preset"] = meta.ResolutionPreset } @@ -666,6 +957,52 @@ func (s *ImageService) writeImageMetadata(rel string, meta imageMetadata) error if meta.OutputFormat != "" { value["output_format"] = meta.OutputFormat } + if meta.OutputCompression != nil { + value["output_compression"] = *meta.OutputCompression + } + if meta.Background != "" { + value["background"] = meta.Background + } + if meta.Moderation != "" { + value["moderation"] = meta.Moderation + } + if meta.Style != "" { + value["style"] = meta.Style + } + if meta.PartialImages != nil { + value["partial_images"] = *meta.PartialImages + } + if meta.InputImageMask != "" { + value["input_image_mask"] = meta.InputImageMask + } + if meta.SharePromptParams { + value["share_prompt_parameters"] = true + } + if meta.ShareReferences { + value["share_reference_images"] = true + } + if len(meta.ReferenceImages) > 0 { + refs := make([]map[string]any, 0, len(meta.ReferenceImages)) + for _, ref := range meta.ReferenceImages { + if ref.Path == "" { + continue + } + item := map[string]any{"path": ref.Path} + if ref.Filename != "" { + item["filename"] = ref.Filename + } + if ref.ContentType != "" { + item["content_type"] = ref.ContentType + } + if ref.Size > 0 { + item["size"] = ref.Size + } + refs = append(refs, item) + } + if len(refs) > 0 { + value["reference_images"] = refs + } + } if s.store != nil { return s.store.SaveJSONDocument(imageOwnerDocumentName(rel), value) } @@ -691,6 +1028,334 @@ func (s *ImageService) removeImageOwner(rel string) error { return nil } +func (s *ImageService) imageReferencesDir() string { + return filepath.Join(s.config.ImageMetadataDir(), imageReferencePrefix) +} + +func (s *ImageService) writeImageReferencesForRef(ref imageFileRef, refs []GeneratedImageReference) []imageReferenceMetadata { + if len(refs) == 0 { + return nil + } + if err := s.removeImageReferences(ref.rel); err != nil { + return nil + } + root, err := filepath.Abs(s.imageReferencesDir()) + if err != nil { + return nil + } + dir := filepath.Join(root, filepath.FromSlash(ref.rel+".refs")) + if !pathInsideRoot(root, dir) { + return nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil + } + result := make([]imageReferenceMetadata, 0, len(refs)) + for index, source := range refs { + if len(source.Data) == 0 { + continue + } + filename := safeImageReferenceFilename(source.Filename, index) + rel := filepath.ToSlash(filepath.Join(ref.rel+".refs", strconv.Itoa(index+1)+"-"+filename)) + if _, err := cleanImageReferenceRelativePath(rel); err != nil { + continue + } + path := filepath.Join(root, filepath.FromSlash(rel)) + if !pathInsideRoot(root, path) { + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + continue + } + if err := os.WriteFile(path, source.Data, 0o644); err != nil { + continue + } + result = append(result, imageReferenceMetadata{ + Path: rel, + Filename: strings.TrimSpace(source.Filename), + ContentType: strings.TrimSpace(source.ContentType), + Size: int64(len(source.Data)), + }) + } + if len(result) == 0 { + _ = os.Remove(dir) + removeEmptyParentDirs(root, filepath.Dir(dir)) + } + return result +} + +func (s *ImageService) removeImageReferences(sourceRel string) error { + sourceRel, err := cleanImageRelativePath(sourceRel) + if err != nil { + return err + } + root, err := filepath.Abs(s.imageReferencesDir()) + if err != nil { + return err + } + dir := filepath.Join(root, filepath.FromSlash(sourceRel+".refs")) + if !pathInsideRoot(root, dir) { + return errors.New("invalid image path") + } + removeErr := os.RemoveAll(dir) + if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return removeErr + } + removeEmptyParentDirs(root, filepath.Dir(dir)) + return nil +} + +func (s *ImageService) imageCleanupCandidates() []imageCleanupCandidate { + root, err := filepath.Abs(s.config.ImagesDir()) + if err != nil { + return nil + } + candidates := make([]imageCleanupCandidate, 0) + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil + } + rel = filepath.ToSlash(rel) + info, statErr := d.Info() + if statErr != nil { + return nil + } + meta := s.imageMetadata(rel) + candidates = append(candidates, imageCleanupCandidate{ + rel: rel, + path: path, + info: info, + meta: meta, + groupSize: s.imageGroupSize(rel, info.Size()), + }) + return nil + }) + return candidates +} + +func (s *ImageService) cleanupByRetention(retentionDays int, includePublic bool) (imageStorageRemovalStats, int, error) { + if retentionDays < 1 { + retentionDays = 1 + } + cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour) + var total imageStorageRemovalStats + preservedPublic := 0 + for _, candidate := range s.imageCleanupCandidates() { + if !candidate.info.ModTime().Before(cutoff) { + continue + } + if candidate.meta.Visibility == ImageVisibilityPublic && !includePublic { + preservedPublic++ + continue + } + stats, err := s.removeImageGroup(candidate.rel) + if err != nil { + return total, preservedPublic, err + } + total.add(stats) + } + return total, preservedPublic, nil +} + +func (s *ImageService) cleanupByStorageLimit(maxBytes int64, includePublic bool) (imageStorageRemovalStats, int, error) { + if maxBytes <= 0 { + return imageStorageRemovalStats{}, 0, nil + } + summary := s.StorageGovernance() + if summary.TotalBytes <= maxBytes { + return imageStorageRemovalStats{}, 0, nil + } + candidates := s.imageCleanupCandidates() + sort.Slice(candidates, func(i, j int) bool { + leftPublic := candidates[i].meta.Visibility == ImageVisibilityPublic + rightPublic := candidates[j].meta.Visibility == ImageVisibilityPublic + if leftPublic != rightPublic { + return !leftPublic + } + return candidates[i].info.ModTime().Before(candidates[j].info.ModTime()) + }) + current := summary.TotalBytes + var total imageStorageRemovalStats + preservedPublic := 0 + for _, candidate := range candidates { + if current <= maxBytes { + break + } + if candidate.meta.Visibility == ImageVisibilityPublic && !includePublic { + preservedPublic++ + continue + } + stats, err := s.removeImageGroup(candidate.rel) + if err != nil { + return total, preservedPublic, err + } + total.add(stats) + if stats.bytes > 0 { + current -= stats.bytes + } else { + current -= candidate.groupSize + } + } + return total, preservedPublic, nil +} + +func (s *ImageService) removeImageGroup(rel string) (imageStorageRemovalStats, error) { + rel, err := cleanImageRelativePath(rel) + if err != nil { + return imageStorageRemovalStats{}, err + } + var stats imageStorageRemovalStats + thumbnailRoot, err := filepath.Abs(s.config.ImageThumbnailsDir()) + if err != nil { + return stats, err + } + if removed, bytes, err := s.removeImageThumbnailWithStats(thumbnailRoot, rel); err != nil { + return stats, err + } else if removed > 0 { + stats.thumbnails++ + if removed > 1 { + stats.metadataFiles += removed - 1 + } + stats.bytes += bytes + } + if removed, bytes, err := s.removeImageReferencesWithStats(rel); err != nil { + return stats, err + } else { + stats.referenceFiles += removed + stats.bytes += bytes + } + if removed, bytes, err := s.removeImageOwnerWithStats(rel); err != nil { + return stats, err + } else { + stats.metadataFiles += removed + stats.bytes += bytes + } + imageRoot, err := filepath.Abs(s.config.ImagesDir()) + if err != nil { + return stats, err + } + imagePath := filepath.Join(imageRoot, filepath.FromSlash(rel)) + if !pathInsideRoot(imageRoot, imagePath) { + return stats, errors.New("invalid image path") + } + if removed, bytes, err := removeFileWithStats(imagePath); err != nil { + return stats, err + } else if removed { + stats.images++ + stats.bytes += bytes + } + removeEmptyParentDirs(imageRoot, filepath.Dir(imagePath)) + return stats, nil +} + +func (s *ImageService) removeImageOwnerWithStats(rel string) (int, int64, error) { + if s.store != nil { + if err := s.store.DeleteJSONDocument(imageOwnerDocumentName(rel)); err != nil { + return 0, 0, err + } + return 1, 0, nil + } + metaPath, err := s.imageOwnerMetadataPath(rel) + if err != nil { + return 0, 0, err + } + removed, bytes, err := removeFileWithStats(metaPath) + if err != nil { + return 0, 0, err + } + if removed { + removeEmptyParentDirs(s.config.ImageMetadataDir(), filepath.Dir(metaPath)) + return 1, bytes, nil + } + return 0, 0, nil +} + +func (s *ImageService) removeImageReferencesWithStats(sourceRel string) (int, int64, error) { + sourceRel, err := cleanImageRelativePath(sourceRel) + if err != nil { + return 0, 0, err + } + root, err := filepath.Abs(s.imageReferencesDir()) + if err != nil { + return 0, 0, err + } + dir := filepath.Join(root, filepath.FromSlash(sourceRel+".refs")) + if !pathInsideRoot(root, dir) { + return 0, 0, errors.New("invalid image path") + } + bytes, files := directorySize(dir, "") + removeErr := os.RemoveAll(dir) + if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return 0, 0, removeErr + } + removeEmptyParentDirs(root, filepath.Dir(dir)) + return files, bytes, nil +} + +func (s *ImageService) removeImageThumbnailWithStats(root, rel string) (int, int64, error) { + thumbPath := filepath.Join(root, filepath.FromSlash(rel)+thumbnailExtension) + if !pathInsideRoot(root, thumbPath) { + return 0, 0, errors.New("invalid image path") + } + removed := 0 + var bytes int64 + if didRemove, size, err := removeFileWithStats(thumbPath); err != nil { + return 0, 0, err + } else if didRemove { + removed++ + bytes += size + } + if didRemove, size, err := removeFileWithStats(thumbPath + ".json"); err != nil { + return 0, 0, err + } else if didRemove { + removed++ + bytes += size + } + if s.store != nil { + if err := s.store.DeleteJSONDocument(thumbnailMetadataDocumentName(rel)); err != nil { + return 0, 0, err + } + } + removeEmptyParentDirs(root, filepath.Dir(thumbPath)) + return removed, bytes, nil +} + +func (s *ImageService) clearThumbnailCache() (imageStorageRemovalStats, error) { + root := s.config.ImageThumbnailsDir() + bytes, thumbnails, metadataFiles := thumbnailCacheStats(root) + if err := os.RemoveAll(root); err != nil && !errors.Is(err, os.ErrNotExist) { + return imageStorageRemovalStats{}, err + } + if err := os.MkdirAll(root, 0o755); err != nil { + return imageStorageRemovalStats{}, err + } + return imageStorageRemovalStats{bytes: bytes, thumbnails: thumbnails, metadataFiles: metadataFiles}, nil +} + +func (s *ImageService) imageGroupSize(rel string, imageSize int64) int64 { + total := imageSize + thumbPath := s.thumbnailPath(rel) + for _, path := range []string{thumbPath, thumbPath + ".json"} { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + total += info.Size() + } + } + metaPath, err := s.imageOwnerMetadataPath(rel) + if err == nil { + if info, statErr := os.Stat(metaPath); statErr == nil && !info.IsDir() { + total += info.Size() + } + } + refDir := filepath.Join(s.imageReferencesDir(), filepath.FromSlash(rel+".refs")) + refBytes, _ := directorySize(refDir, "") + total += refBytes + return total +} + func (s *ImageService) imageOwnerMetadataPath(rel string) (string, error) { rel, err := cleanImageRelativePath(rel) if err != nil { @@ -739,6 +1404,100 @@ func imageOwnerDocumentName(rel string) string { return "image_metadata/" + filepath.ToSlash(rel) + ".json" } +type imageMetadataFieldOptions struct { + BaseURL string + IncludeReusableFields bool + IncludeReferenceImages bool +} + +func addImageMetadataFields(item map[string]any, meta imageMetadata, optionsValues ...imageMetadataFieldOptions) { + options := imageMetadataFieldOptions{IncludeReusableFields: true, IncludeReferenceImages: true} + if len(optionsValues) > 0 { + options = optionsValues[0] + } + if meta.OwnerID != "" { + item["owner_id"] = meta.OwnerID + } + if meta.OwnerName != "" { + item["owner_name"] = meta.OwnerName + } + if meta.PublishedAt != "" { + item["published_at"] = meta.PublishedAt + } + item["share_prompt_parameters"] = meta.SharePromptParams + item["share_reference_images"] = meta.ShareReferences + if options.IncludeReusableFields { + if meta.Prompt != "" { + item["prompt"] = meta.Prompt + } + if meta.Model != "" { + item["model"] = meta.Model + } + if meta.Quality != "" { + item["quality"] = meta.Quality + } + if meta.ResolutionPreset != "" { + item["resolution_preset"] = meta.ResolutionPreset + } + if meta.RequestedSize != "" { + item["requested_size"] = meta.RequestedSize + } + if meta.OutputFormat != "" { + item["output_format"] = meta.OutputFormat + } + if meta.OutputCompression != nil { + item["output_compression"] = *meta.OutputCompression + } + if meta.Background != "" { + item["background"] = meta.Background + } + if meta.Moderation != "" { + item["moderation"] = meta.Moderation + } + if meta.Style != "" { + item["style"] = meta.Style + } + if meta.PartialImages != nil { + item["partial_images"] = *meta.PartialImages + } + if meta.InputImageMask != "" { + item["input_image_mask"] = meta.InputImageMask + } + } + if options.IncludeReferenceImages && len(meta.ReferenceImages) > 0 { + baseURL := strings.TrimSpace(options.BaseURL) + referenceItems := make([]map[string]any, 0, len(meta.ReferenceImages)) + referenceURLs := make([]string, 0, len(meta.ReferenceImages)) + for _, ref := range meta.ReferenceImages { + if ref.Path == "" { + continue + } + refItem := map[string]any{"path": ref.Path} + if ref.Filename != "" { + refItem["filename"] = ref.Filename + } + if ref.ContentType != "" { + refItem["content_type"] = ref.ContentType + } + if ref.Size > 0 { + refItem["size"] = ref.Size + } + if baseURL != "" { + url := publicAssetURL(baseURL, "image-references", ref.Path) + refItem["url"] = url + referenceURLs = append(referenceURLs, url) + } + referenceItems = append(referenceItems, refItem) + } + if len(referenceItems) > 0 { + item["reference_images"] = referenceItems + } + if len(referenceURLs) > 0 { + item["reference_image_urls"] = referenceURLs + } + } +} + func NormalizeImageVisibility(value string) (string, error) { switch strings.TrimSpace(value) { case "", ImageVisibilityPrivate: @@ -916,6 +1675,135 @@ func imageRelativePathFromValue(value string) (string, error) { return cleanImageRelativePath(text) } +func cleanImageReferenceRelativePath(value string) (string, error) { + rel, err := cleanImageRelativePath(value) + if err != nil { + return "", err + } + if _, err := sourceImageRelativePathFromReference(rel); err != nil { + return "", err + } + return rel, nil +} + +func imageReferenceRelativePathFromValue(value string) (string, error) { + text := strings.TrimSpace(value) + if text == "" { + return "", errors.New("invalid image path") + } + if parsed, err := url.Parse(text); err == nil { + pathValue := parsed.EscapedPath() + if pathValue == "" { + pathValue = parsed.Path + } + if parsed.Scheme != "" || strings.HasPrefix(pathValue, "/") { + const imageReferencePrefix = "/image-references/" + index := strings.Index(pathValue, imageReferencePrefix) + if index < 0 { + return "", errors.New("invalid image path") + } + rel, err := url.PathUnescape(pathValue[index+len(imageReferencePrefix):]) + if err != nil { + return "", errors.New("invalid image path") + } + return cleanImageReferenceRelativePath(rel) + } + } + return cleanImageReferenceRelativePath(text) +} + +func sourceImageRelativePathFromReference(value string) (string, error) { + rel, err := cleanImageRelativePath(value) + if err != nil { + return "", err + } + index := strings.LastIndex(rel, imageReferenceMarker) + if index <= 0 || index+len(imageReferenceMarker) >= len(rel) { + return "", errors.New("invalid image path") + } + return cleanImageRelativePath(rel[:index]) +} + +func normalizeImageReferenceMetadata(value any) []imageReferenceMetadata { + items := imageReferenceMetadataItems(value) + if len(items) == 0 { + return nil + } + refs := make([]imageReferenceMetadata, 0, len(items)) + for _, item := range items { + rel, err := cleanImageReferenceRelativePath(toString(item["path"])) + if err != nil { + continue + } + refs = append(refs, imageReferenceMetadata{ + Path: rel, + Filename: strings.TrimSpace(toString(item["filename"])), + ContentType: strings.TrimSpace(toString(item["content_type"])), + Size: int64(numericMetaValue(item["size"])), + }) + } + return refs +} + +func safeImageReferenceFilename(value string, index int) string { + name := filepath.Base(filepath.ToSlash(strings.TrimSpace(value))) + if name == "." || name == "/" || name == "" { + name = "reference-" + strconv.Itoa(index+1) + ".png" + } + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + clean := strings.Trim(b.String(), ".- _") + if clean == "" { + clean = "reference-" + strconv.Itoa(index+1) + ".png" + } + if !strings.Contains(filepath.Base(clean), ".") { + clean += ".png" + } + if len(clean) > 96 { + ext := filepath.Ext(clean) + stem := strings.TrimSuffix(clean, ext) + limit := 96 - len(ext) + if limit < 1 { + return clean[:96] + } + if len(stem) > limit { + stem = stem[:limit] + } + clean = stem + ext + } + return clean +} + +func imageReferenceMetadataItems(value any) []map[string]any { + switch v := value.(type) { + case []map[string]any: + return v + case []any: + items := make([]map[string]any, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + items = append(items, m) + } + } + return items + default: + return nil + } +} + func removeImageThumbnail(root, rel string) error { thumbPath := filepath.Join(root, filepath.FromSlash(rel)+thumbnailExtension) if !pathInsideRoot(root, thumbPath) { @@ -933,6 +1821,96 @@ func removeImageThumbnail(root, rel string) error { return nil } +func (s *imageStorageRemovalStats) add(next imageStorageRemovalStats) { + s.bytes += next.bytes + s.images += next.images + s.thumbnails += next.thumbnails + s.metadataFiles += next.metadataFiles + s.referenceFiles += next.referenceFiles +} + +func removeFileWithStats(path string) (bool, int64, error) { + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, 0, nil + } + return false, 0, err + } + if info.IsDir() { + return false, 0, nil + } + size := info.Size() + if err := os.Remove(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, 0, nil + } + return false, 0, err + } + return true, size, nil +} + +func directorySize(root, skipPrefix string) (int64, int) { + root = strings.TrimSpace(root) + if root == "" { + return 0, 0 + } + if skipPrefix != "" { + if abs, err := filepath.Abs(skipPrefix); err == nil { + skipPrefix = abs + } + } + var total int64 + files := 0 + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if skipPrefix != "" { + if abs, absErr := filepath.Abs(path); absErr == nil && (abs == skipPrefix || strings.HasPrefix(abs, skipPrefix+string(os.PathSeparator))) { + if d.IsDir() && abs != root { + return filepath.SkipDir + } + return nil + } + } + if d.IsDir() { + return nil + } + info, statErr := d.Info() + if statErr != nil { + return nil + } + total += info.Size() + files++ + return nil + }) + return total, files +} + +func thumbnailCacheStats(root string) (int64, int, int) { + var bytes int64 + thumbnails := 0 + metadataFiles := 0 + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + info, statErr := d.Info() + if statErr != nil { + return nil + } + bytes += info.Size() + if strings.HasSuffix(path, ".json") { + metadataFiles++ + } else { + thumbnails++ + } + return nil + }) + return bytes, thumbnails, metadataFiles +} + func writeJPEGThumbnail(path string, img image.Image) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err @@ -1017,15 +1995,82 @@ func isCurrentThumbnailMetadata(meta map[string]any) bool { } func numericMetaValue(value any) int { + n, _ := imageMetadataIntValue(value) + return n +} + +func imageMetadataIntValue(value any) (int, bool) { switch v := value.(type) { case int: - return v + return v, true case int64: - return int(v) + return int(v), true case float64: - return int(v) + return int(v), true + case json.Number: + n, err := v.Int64() + if err == nil { + return int(n), true + } + case string: + text := strings.TrimSpace(v) + if text == "" { + return 0, false + } + n, err := strconv.Atoi(text) + if err == nil { + return n, true + } default: - return 0 + return 0, false + } + return 0, false +} + +func imageOutputCompressionMetadata(value any) *int { + compression, ok := imageMetadataIntValue(value) + if !ok { + return nil + } + if compression < 0 { + compression = 0 + } else if compression > 100 { + compression = 100 + } + return &compression +} + +func positiveImageMetadataInt(value any) *int { + count, ok := imageMetadataIntValue(value) + if !ok { + return nil + } + if count <= 0 { + return nil + } + return &count +} + +func boolMetadataValue(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } + case float64: + return v != 0 + case int: + return v != 0 + case json.Number: + n, err := v.Int64() + return err == nil && n != 0 + default: + return false } } diff --git a/internal/service/image_conversation_session.go b/internal/service/image_conversation_session.go new file mode 100644 index 000000000..d944c298f --- /dev/null +++ b/internal/service/image_conversation_session.go @@ -0,0 +1,202 @@ +package service + +import ( + "sort" + "strings" + "sync" + "time" + + "chatgpt2api/internal/storage" + "chatgpt2api/internal/util" +) + +const ( + ImageConversationSessionActive = "active" + ImageConversationSessionFailed = "failed" + + imageConversationSessionDocumentName = "image_conversation_sessions.json" +) + +type ImageConversationSession struct { + OwnerID string `json:"owner_id"` + FrontendConversationID string `json:"frontend_conversation_id"` + AccessToken string `json:"access_token"` + UpstreamConversationID string `json:"upstream_conversation_id"` + UpstreamParentMessageID string `json:"upstream_parent_message_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` +} + +type ImageConversationSessionService struct { + mu sync.RWMutex + path string + store storage.JSONDocumentBackend + docName string + items map[string]ImageConversationSession +} + +func NewImageConversationSessionService(path string, backends ...storage.Backend) *ImageConversationSessionService { + s := &ImageConversationSessionService{ + path: path, + store: firstJSONDocumentStore(backends), + docName: imageConversationSessionDocumentName, + items: map[string]ImageConversationSession{}, + } + s.items = s.load() + return s +} + +func (s *ImageConversationSessionService) Get(ownerID, frontendConversationID string) (ImageConversationSession, bool) { + if s == nil { + return ImageConversationSession{}, false + } + key := imageConversationSessionKey(ownerID, frontendConversationID) + if key == "" { + return ImageConversationSession{}, false + } + s.mu.RLock() + defer s.mu.RUnlock() + item, ok := s.items[key] + return item, ok +} + +func (s *ImageConversationSessionService) Bind(item ImageConversationSession) { + if s == nil { + return + } + item.OwnerID = util.Clean(item.OwnerID) + item.FrontendConversationID = util.Clean(item.FrontendConversationID) + item.AccessToken = strings.TrimSpace(item.AccessToken) + item.UpstreamConversationID = util.Clean(item.UpstreamConversationID) + item.UpstreamParentMessageID = util.Clean(item.UpstreamParentMessageID) + key := imageConversationSessionKey(item.OwnerID, item.FrontendConversationID) + if key == "" || item.AccessToken == "" || item.UpstreamConversationID == "" || item.UpstreamParentMessageID == "" { + return + } + + now := time.Now().UTC() + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.items[key]; ok && item.CreatedAt.IsZero() { + item.CreatedAt = existing.CreatedAt + } + if item.CreatedAt.IsZero() { + item.CreatedAt = now + } + if item.LastUsedAt.IsZero() { + item.LastUsedAt = now + } + item.Status = ImageConversationSessionActive + if s.items == nil { + s.items = map[string]ImageConversationSession{} + } + s.items[key] = item + _ = s.saveLocked() +} + +func (s *ImageConversationSessionService) Invalidate(ownerID, frontendConversationID string) { + if s == nil { + return + } + key := imageConversationSessionKey(ownerID, frontendConversationID) + if key == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + item, ok := s.items[key] + if !ok { + return + } + item.Status = ImageConversationSessionFailed + item.LastUsedAt = time.Now().UTC() + s.items[key] = item + _ = s.saveLocked() +} + +func (s *ImageConversationSessionService) Cleanup(maxAge time.Duration) int { + if s == nil || maxAge <= 0 { + return 0 + } + cutoff := time.Now().UTC().Add(-maxAge) + s.mu.Lock() + defer s.mu.Unlock() + removed := 0 + for key, item := range s.items { + lastUsed := item.LastUsedAt + if lastUsed.IsZero() { + lastUsed = item.CreatedAt + } + if !lastUsed.IsZero() && lastUsed.Before(cutoff) { + delete(s.items, key) + removed++ + } + } + if removed > 0 { + _ = s.saveLocked() + } + return removed +} + +func (s *ImageConversationSessionService) load() map[string]ImageConversationSession { + raw := loadStoredJSON(s.store, s.docName) + if obj, ok := raw.(map[string]any); ok { + raw = obj["sessions"] + } + items := map[string]ImageConversationSession{} + for _, rawItem := range util.AsMapSlice(raw) { + item := ImageConversationSession{ + OwnerID: util.Clean(rawItem["owner_id"]), + FrontendConversationID: util.Clean(rawItem["frontend_conversation_id"]), + AccessToken: strings.TrimSpace(util.Clean(rawItem["access_token"])), + UpstreamConversationID: util.Clean(rawItem["upstream_conversation_id"]), + UpstreamParentMessageID: util.Clean(rawItem["upstream_parent_message_id"]), + Status: util.Clean(rawItem["status"]), + CreatedAt: parseImageConversationSessionTime(rawItem["created_at"]), + LastUsedAt: parseImageConversationSessionTime(rawItem["last_used_at"]), + } + key := imageConversationSessionKey(item.OwnerID, item.FrontendConversationID) + if key == "" || item.AccessToken == "" || item.UpstreamConversationID == "" || item.UpstreamParentMessageID == "" { + continue + } + if item.Status != ImageConversationSessionFailed { + item.Status = ImageConversationSessionActive + } + items[key] = item + } + return items +} + +func (s *ImageConversationSessionService) saveLocked() error { + items := make([]ImageConversationSession, 0, len(s.items)) + for _, item := range s.items { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + return items[i].LastUsedAt.After(items[j].LastUsedAt) + }) + return saveStoredJSON(s.store, s.docName, map[string]any{"sessions": items}) +} + +func imageConversationSessionKey(ownerID, frontendConversationID string) string { + ownerID = util.Clean(ownerID) + frontendConversationID = util.Clean(frontendConversationID) + if ownerID == "" || frontendConversationID == "" { + return "" + } + return ownerID + "\x00" + frontendConversationID +} + +func parseImageConversationSessionTime(value any) time.Time { + if t, ok := value.(time.Time); ok { + return t + } + text := util.Clean(value) + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05", "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, text); err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/internal/service/image_conversation_session_test.go b/internal/service/image_conversation_session_test.go new file mode 100644 index 000000000..8b19a5597 --- /dev/null +++ b/internal/service/image_conversation_session_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "path/filepath" + "testing" + "time" + + "chatgpt2api/internal/storage" +) + +func newImageConversationSessionTestBackend(t *testing.T) storage.Backend { + t.Helper() + backend, err := storage.NewDatabaseBackend("sqlite:///" + filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))) + if err != nil { + t.Fatalf("NewDatabaseBackend() error = %v", err) + } + t.Cleanup(func() { _ = backend.Close() }) + return backend +} + +func TestImageConversationSessionServiceScopesBindings(t *testing.T) { + backend := newImageConversationSessionTestBackend(t) + svc := NewImageConversationSessionService(filepath.Join(t.TempDir(), "image_conversation_sessions.json"), backend) + + if _, ok := svc.Get("owner-a", "frontend-1"); ok { + t.Fatal("Get() found binding before Bind()") + } + + first := ImageConversationSession{ + OwnerID: "owner-a", + FrontendConversationID: "frontend-1", + AccessToken: "token-a", + UpstreamConversationID: "conv-a", + UpstreamParentMessageID: "msg-a", + } + svc.Bind(first) + + if _, ok := svc.Get("owner-b", "frontend-1"); ok { + t.Fatal("Get() leaked binding across owners") + } + got, ok := svc.Get("owner-a", "frontend-1") + if !ok { + t.Fatal("Get() did not find owner binding") + } + if got.AccessToken != "token-a" || got.UpstreamConversationID != "conv-a" || got.UpstreamParentMessageID != "msg-a" || got.Status != ImageConversationSessionActive { + t.Fatalf("binding = %#v", got) + } +} + +func TestImageConversationSessionServiceOverwriteInvalidateCleanupAndReload(t *testing.T) { + path := filepath.Join(t.TempDir(), "image_conversation_sessions.json") + backend := newImageConversationSessionTestBackend(t) + svc := NewImageConversationSessionService(path, backend) + svc.Bind(ImageConversationSession{OwnerID: "owner", FrontendConversationID: "front", AccessToken: "old", UpstreamConversationID: "conv-old", UpstreamParentMessageID: "msg-old"}) + svc.Bind(ImageConversationSession{OwnerID: "owner", FrontendConversationID: "front", AccessToken: "new", UpstreamConversationID: "conv-new", UpstreamParentMessageID: "msg-new"}) + + got, ok := svc.Get("owner", "front") + if !ok || got.AccessToken != "new" || got.UpstreamConversationID != "conv-new" || got.UpstreamParentMessageID != "msg-new" { + t.Fatalf("overwritten binding = %#v ok=%v", got, ok) + } + + reloaded := NewImageConversationSessionService(path, backend) + reloadedGot, ok := reloaded.Get("owner", "front") + if !ok || reloadedGot.AccessToken != "new" || reloadedGot.UpstreamConversationID != "conv-new" || reloadedGot.UpstreamParentMessageID != "msg-new" { + t.Fatalf("reloaded binding = %#v ok=%v", reloadedGot, ok) + } + + reloaded.Invalidate("owner", "front") + invalid, ok := reloaded.Get("owner", "front") + if !ok || invalid.Status != ImageConversationSessionFailed { + t.Fatalf("invalidated binding = %#v ok=%v", invalid, ok) + } + + old := time.Now().Add(-48 * time.Hour) + reloaded.Bind(ImageConversationSession{OwnerID: "owner", FrontendConversationID: "old", AccessToken: "token", UpstreamConversationID: "conv", UpstreamParentMessageID: "msg", LastUsedAt: old}) + removed := reloaded.Cleanup(24 * time.Hour) + if removed != 1 { + t.Fatalf("Cleanup() removed %d, want 1", removed) + } + if _, ok := reloaded.Get("owner", "old"); ok { + t.Fatal("Cleanup() kept expired binding") + } +} diff --git a/internal/service/image_task.go b/internal/service/image_task.go index a70c7c19d..98f754537 100644 --- a/internal/service/image_task.go +++ b/internal/service/image_task.go @@ -2,10 +2,7 @@ package service import ( "context" - "encoding/json" "fmt" - "os" - "path/filepath" "sort" "strings" "sync" @@ -24,8 +21,11 @@ const ( defaultImageTaskTimeout = 5 * time.Minute - imageOutputCallbackPayloadKey = "image_output_callback" - imageOutputSlotAcquirerPayloadKey = "image_output_slot_acquirer" + imageOutputCallbackPayloadKey = "image_output_callback" + imageOutputSlotAcquirerPayloadKey = "image_output_slot_acquirer" + imageTaskBillingBillablePayloadKey = "billing_billable" + imageTaskBillingChargedAmountKey = "billing_charged_amount" + imageTaskBillingChargeKey = "billing_charge_key" ) type ImageTaskHandler func(context.Context, Identity, map[string]any) (map[string]any, error) @@ -45,12 +45,12 @@ type ImageToolOptions struct { type ImageTaskService struct { mu sync.RWMutex - path string store storage.JSONDocumentBackend docName string generation ImageTaskHandler edit ImageTaskHandler chat ImageTaskHandler + billing *BillingService retentionGetter func() int taskTimeoutGetter func() time.Duration userConcurrentLimit func() int @@ -70,16 +70,12 @@ func (e ImageTaskLimitError) Error() string { return e.Message } -func NewImageTaskService(path string, generation ImageTaskHandler, edit ImageTaskHandler, chat ImageTaskHandler, retentionGetter func() int, limitGetters ...func() int) *ImageTaskService { - return newImageTaskService(path, nil, generation, edit, chat, retentionGetter, limitGetters...) +func NewStoredImageTaskService(backend storage.Backend, generation ImageTaskHandler, edit ImageTaskHandler, chat ImageTaskHandler, retentionGetter func() int, limitGetters ...func() int) *ImageTaskService { + return newImageTaskService(jsonDocumentStoreFromBackend(backend), generation, edit, chat, retentionGetter, limitGetters...) } -func NewStoredImageTaskService(path string, backend storage.Backend, generation ImageTaskHandler, edit ImageTaskHandler, chat ImageTaskHandler, retentionGetter func() int, limitGetters ...func() int) *ImageTaskService { - return newImageTaskService(path, jsonDocumentStoreFromBackend(backend), generation, edit, chat, retentionGetter, limitGetters...) -} - -func newImageTaskService(path string, store storage.JSONDocumentBackend, generation ImageTaskHandler, edit ImageTaskHandler, chat ImageTaskHandler, retentionGetter func() int, limitGetters ...func() int) *ImageTaskService { - s := &ImageTaskService{path: path, store: store, docName: "image_tasks.json", generation: generation, edit: edit, chat: chat, retentionGetter: retentionGetter, tasks: map[string]map[string]any{}, cancels: map[string]context.CancelFunc{}, ownerSubmitTimes: map[string][]time.Time{}, ownerRunningUnits: map[string]int{}} +func newImageTaskService(store storage.JSONDocumentBackend, generation ImageTaskHandler, edit ImageTaskHandler, chat ImageTaskHandler, retentionGetter func() int, limitGetters ...func() int) *ImageTaskService { + s := &ImageTaskService{store: store, docName: "image_tasks.json", generation: generation, edit: edit, chat: chat, retentionGetter: retentionGetter, tasks: map[string]map[string]any{}, cancels: map[string]context.CancelFunc{}, ownerSubmitTimes: map[string][]time.Time{}, ownerRunningUnits: map[string]int{}} s.creationUnitCond = sync.NewCond(&s.mu) if len(limitGetters) > 0 { s.userConcurrentLimit = limitGetters[0] @@ -87,7 +83,6 @@ func newImageTaskService(path string, store storage.JSONDocumentBackend, generat if len(limitGetters) > 1 { s.userRPMLimit = limitGetters[1] } - _ = os.MkdirAll(filepath.Dir(path), 0o755) s.mu.Lock() s.tasks = s.loadLocked() changed := s.recoverUnfinishedLocked() @@ -105,6 +100,39 @@ func (s *ImageTaskService) SetTaskTimeoutGetter(getter func() time.Duration) { s.taskTimeoutGetter = getter } +func (s *ImageTaskService) SetBillingService(billing *BillingService) { + s.billing = billing + if billing == nil { + return + } + var settleKeys []string + s.mu.Lock() + changed := false + for key, task := range s.tasks { + taskChanged := false + if _, ok := task["billing_consumed_amount"]; !ok && !isActiveTaskStatus(util.Clean(task["status"])) && isBillableImageTaskMode(util.Clean(task["mode"]), task) && util.ToInt(task[imageTaskBillingChargedAmountKey], 0) > 0 { + settleKeys = append(settleKeys, key) + continue + } + if _, ok := task["billing_consumed_amount"]; !ok && !isActiveTaskStatus(util.Clean(task["status"])) && isBillableImageTaskMode(util.Clean(task["mode"]), task) { + task["billing_consumed_amount"] = billableTaskOutputCount(task) + taskChanged = true + } + if taskChanged { + task["updated_at"] = util.NowLocal() + s.tasks[key] = task + changed = true + } + } + if changed { + _ = s.saveLocked() + } + s.mu.Unlock() + for _, key := range settleKeys { + s.settleTaskBilling(key) + } +} + func (s *ImageTaskService) SubmitGeneration(ctx context.Context, identity Identity, clientTaskID, prompt, model, size, quality, baseURL string, n int, messages any, visibilityValues ...string) (map[string]any, error) { prompt = strings.TrimSpace(prompt) if prompt == "" { @@ -153,7 +181,7 @@ func (s *ImageTaskService) SubmitEditWithOptions(ctx context.Context, identity I return s.submitImageWithMetadataAndOptions(ctx, identity, clientTaskID, prompt, model, size, quality, baseURL, n, messages, metadata, "edit", images, options, toolOptions, visibilityValues...) } -func (s *ImageTaskService) SubmitChat(ctx context.Context, identity Identity, clientTaskID, prompt, model string, messages any) (map[string]any, error) { +func (s *ImageTaskService) SubmitChat(ctx context.Context, identity Identity, clientTaskID, prompt, model string, messages any, billable bool, nValues ...int) (map[string]any, error) { prompt = strings.TrimSpace(prompt) if prompt == "" { return nil, fmt.Errorf("prompt is required") @@ -161,7 +189,14 @@ func (s *ImageTaskService) SubmitChat(ctx context.Context, identity Identity, cl if len(util.AsMapSlice(messages)) == 0 { return nil, fmt.Errorf("messages are required") } - payload := map[string]any{"prompt": prompt, "model": model, "messages": messages, "n": 1, "visibility": ImageVisibilityPrivate} + n := 1 + if len(nValues) > 0 { + n = normalizedImageTaskCount(nValues[0]) + } + payload := map[string]any{"prompt": prompt, "model": model, "messages": messages, "n": n, "visibility": ImageVisibilityPrivate} + if billable { + payload[imageTaskBillingBillablePayloadKey] = true + } return s.submit(ctx, identity, clientTaskID, "chat", payload) } @@ -236,6 +271,7 @@ func (s *ImageTaskService) CancelTask(identity Identity, clientTaskID string) (m var cancel context.CancelFunc s.mu.Lock() task := s.tasks[key] + cancelled := false if task == nil { s.mu.Unlock() return nil, fmt.Errorf("creation task not found") @@ -250,9 +286,13 @@ func (s *ImageTaskService) CancelTask(identity Identity, clientTaskID string) (m cancel = s.cancels[key] delete(s.cancels, key) _ = s.saveLocked() + cancelled = true } result := publicTask(task) s.mu.Unlock() + if cancelled { + s.settleTaskBilling(key) + } if cancel != nil { cancel() } @@ -278,6 +318,17 @@ func (s *ImageTaskService) submit(ctx context.Context, identity Identity, client return result, nil } count := taskCount(mode, payload) + billingUser := billingUserID(identity) + shouldPrechargeBilling := s.billing != nil && identity.Role == AuthRoleUser && billingUser != "" && isBillableImageTaskMode(mode, payload) + if shouldPrechargeBilling { + if err := s.billing.CheckAvailable(identity, count); err != nil { + if cleaned { + _ = s.saveLocked() + } + s.mu.Unlock() + return nil, err + } + } if err := s.checkUserTaskLimitsLocked(identity, owner, count, time.Now()); err != nil { if cleaned { _ = s.saveLocked() @@ -285,9 +336,30 @@ func (s *ImageTaskService) submit(ctx context.Context, identity Identity, client s.mu.Unlock() return nil, err } + billingChargedAmount := 0 + billingChargeKey := "" + if shouldPrechargeBilling { + billingChargeKey = imageTaskBillingChargeKeyFor(owner, taskID, "precharge") + model := firstNonEmpty(util.Clean(payload["model"]), util.ImageModelAuto) + if _, err := s.billing.ChargeUserID(billingUser, count, imageTaskBillingReference(mode, taskID, model, billingChargeKey)); err != nil { + if cleaned { + _ = s.saveLocked() + } + s.mu.Unlock() + return nil, err + } + billingChargedAmount = count + } taskCtx, cancel := context.WithCancel(context.Background()) outputFormat := NormalizeImageOutputFormat(util.Clean(payload["output_format"])) task := map[string]any{"id": taskID, "owner_id": owner, "status": TaskStatusQueued, "mode": mode, "model": firstNonEmpty(util.Clean(payload["model"]), util.ImageModelAuto), "size": util.Clean(payload["size"]), "quality": util.Clean(payload["quality"]), "output_format": outputFormat, "visibility": util.Clean(payload["visibility"]), "count": count, "created_at": now, "updated_at": now} + if billingChargedAmount > 0 { + task[imageTaskBillingChargedAmountKey] = billingChargedAmount + task[imageTaskBillingChargeKey] = billingChargeKey + } + if util.ToBool(payload[imageTaskBillingBillablePayloadKey]) { + task[imageTaskBillingBillablePayloadKey] = true + } if mode == "generate" || mode == "edit" { task["output_statuses"] = initialImageOutputStatuses(count) } @@ -369,11 +441,24 @@ func (s *ImageTaskService) runTask(ctx context.Context, key, mode string, identi } else if runCtx.Err() == context.DeadlineExceeded { message = "图片生成超时,请稍后重试或降低分辨率" } - updates := map[string]any{"status": status, "error": message, "data": taskResultData(result)} - if outputType := util.Clean(result["output_type"]); outputType != "" { + data := taskResultData(result) + outputType := util.Clean(result["output_type"]) + if outputType == "text" && len(data) == 0 && ctx.Err() == nil && runCtx.Err() != context.DeadlineExceeded { + if text := util.Clean(result["message"]); text != "" { + data = []map[string]any{{"text_response": text}} + status = TaskStatusSuccess + message = "" + } + } + updates := map[string]any{"status": status, "error": message, "data": data} + if outputType != "" { updates["output_type"] = outputType } + if mode == "generate" || mode == "edit" { + updates["output_statuses"] = finalImageOutputStatuses(taskCount(mode, payload), data, status) + } s.updateActiveTask(key, updates) + s.settleTaskBilling(key) return } data := util.AsMapSlice(result["data"]) @@ -390,25 +475,41 @@ func (s *ImageTaskService) runTask(ctx context.Context, key, mode string, identi updates["output_type"] = outputType } s.updateActiveTask(key, updates) + s.settleTaskBilling(key) return } updates := map[string]any{"status": TaskStatusSuccess, "data": data, "error": ""} if mode == "generate" || mode == "edit" { - statuses := initialImageOutputStatuses(taskCount(mode, payload)) - for index, item := range data { - if index >= len(statuses) { - break - } - if hasImageTaskOutputData(item) { - statuses[index] = "success" - } - } - updates["output_statuses"] = statuses + updates["output_statuses"] = finalImageOutputStatuses(taskCount(mode, payload), data, TaskStatusError) } if outputType != "" { updates["output_type"] = outputType } s.updateActiveTask(key, updates) + s.settleTaskBilling(key) +} + +func finalImageOutputStatuses(count int, data []map[string]any, status string) []string { + statuses := initialImageOutputStatuses(count) + if len(statuses) == 0 { + return statuses + } + fallback := status + if fallback != TaskStatusCancelled { + fallback = TaskStatusError + } + for index := range statuses { + statuses[index] = fallback + } + for index, item := range data { + if index >= len(statuses) { + break + } + if hasImageTaskOutputData(item) { + statuses[index] = TaskStatusSuccess + } + } + return statuses } func (s *ImageTaskService) taskTimeout() time.Duration { @@ -601,6 +702,89 @@ func (s *ImageTaskService) updateImageTaskPartialData(key string, data []map[str return true } +type imageTaskBillingSettlement struct { + owner string + taskID string + mode string + model string + chargeKey string + refundKey string + charged int + consumed int + refundAmount int +} + +func (s *ImageTaskService) settleTaskBilling(key string) { + settlement, ok := s.pendingTaskBillingSettlement(key) + if !ok { + return + } + if settlement.refundAmount > 0 { + if s.billing == nil { + return + } + if _, err := s.billing.RefundUserID(settlement.owner, settlement.refundAmount, BillingReference{ + Endpoint: creationTaskBillingEndpoint(settlement.mode), + Model: settlement.model, + TaskID: settlement.taskID, + ChargeKey: settlement.refundKey, + RefundForKey: settlement.chargeKey, + }); err != nil { + return + } + } + s.finishTaskBillingSettlement(key, settlement.consumed) +} + +func (s *ImageTaskService) pendingTaskBillingSettlement(key string) (imageTaskBillingSettlement, bool) { + s.mu.Lock() + defer s.mu.Unlock() + task := s.tasks[key] + if task == nil || !isBillableImageTaskMode(util.Clean(task["mode"]), task) || util.ToInt(task["billing_consumed_amount"], -1) >= 0 { + return imageTaskBillingSettlement{}, false + } + mode := util.Clean(task["mode"]) + charged := util.ToInt(task[imageTaskBillingChargedAmountKey], 0) + consumed := 0 + if task["status"] == TaskStatusSuccess { + consumed = billableTaskOutputCount(task) + } + if charged > 0 && consumed > charged { + consumed = charged + } + owner := util.Clean(task["owner_id"]) + taskID := util.Clean(task["id"]) + chargeKey := util.Clean(task[imageTaskBillingChargeKey]) + if chargeKey == "" && charged > 0 { + chargeKey = imageTaskBillingChargeKeyFor(owner, taskID, "precharge") + } + return imageTaskBillingSettlement{ + owner: owner, + taskID: taskID, + mode: mode, + model: firstNonEmpty(util.Clean(task["model"]), util.ImageModelAuto), + chargeKey: chargeKey, + refundKey: imageTaskBillingChargeKeyFor(owner, taskID, "refund"), + charged: charged, + consumed: consumed, + refundAmount: max(0, charged-consumed), + }, true +} + +func (s *ImageTaskService) finishTaskBillingSettlement(key string, consumed int) { + s.mu.Lock() + defer s.mu.Unlock() + task := s.tasks[key] + if task == nil || util.ToInt(task["billing_consumed_amount"], -1) >= 0 { + return + } + delete(task, imageTaskBillingChargedAmountKey) + delete(task, imageTaskBillingChargeKey) + task["billing_consumed_amount"] = max(0, consumed) + task["updated_at"] = util.NowLocal() + _ = s.saveLocked() +} + func (s *ImageTaskService) removeTaskCancel(key string) { s.mu.Lock() defer s.mu.Unlock() @@ -608,7 +792,7 @@ func (s *ImageTaskService) removeTaskCancel(key string) { } func (s *ImageTaskService) loadLocked() map[string]map[string]any { - raw := loadStoredJSON(s.store, s.docName, s.path) + raw := loadStoredJSON(s.store, s.docName) if obj, ok := raw.(map[string]any); ok { raw = obj["tasks"] } @@ -642,18 +826,30 @@ func (s *ImageTaskService) loadLocked() map[string]map[string]any { normalized["output_compression"] = compression } } - if data := util.AsMapSlice(task["data"]); data != nil { - normalized["data"] = data - } - if statuses := normalizedImageOutputStatuses(mode, count, task["output_statuses"]); len(statuses) > 0 { - normalized["output_statuses"] = statuses - } - if errText := util.Clean(task["error"]); errText != "" { + if data := util.AsMapSlice(task["data"]); data != nil { + normalized["data"] = data + } + if statuses := normalizedImageOutputStatuses(mode, count, task["output_statuses"]); len(statuses) > 0 { + normalized["output_statuses"] = statuses + } + if errText := util.Clean(task["error"]); errText != "" { normalized["error"] = errText } if outputType := util.Clean(task["output_type"]); outputType != "" { normalized["output_type"] = outputType } + if util.ToBool(task[imageTaskBillingBillablePayloadKey]) { + normalized[imageTaskBillingBillablePayloadKey] = true + } + if charged := util.ToInt(task[imageTaskBillingChargedAmountKey], 0); charged > 0 { + normalized[imageTaskBillingChargedAmountKey] = charged + } + if chargeKey := util.Clean(task[imageTaskBillingChargeKey]); chargeKey != "" { + normalized[imageTaskBillingChargeKey] = chargeKey + } + if consumed := util.ToInt(task["billing_consumed_amount"], -1); consumed >= 0 { + normalized["billing_consumed_amount"] = consumed + } tasks[taskKey(owner, id)] = normalized } return tasks @@ -669,15 +865,7 @@ func (s *ImageTaskService) saveLocked() error { if s.store != nil { return s.store.SaveJSONDocument(s.docName, value) } - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { - return err - } - return os.Rename(tmp, s.path) + return fmt.Errorf("storage document backend is required") } func (s *ImageTaskService) recoverUnfinishedLocked() bool { @@ -742,6 +930,9 @@ func publicTask(task map[string]any) map[string]any { if util.Clean(task["output_type"]) != "" { item["output_type"] = task["output_type"] } + if consumed := util.ToInt(task["billing_consumed_amount"], -1); consumed >= 0 { + item["billing_consumed_amount"] = consumed + } if visibility := util.Clean(task["visibility"]); visibility != "" { item["visibility"] = visibility } @@ -787,9 +978,6 @@ func imageTaskCount(payload map[string]any) int { } func taskCount(mode string, payload map[string]any) int { - if mode == "chat" { - return 1 - } return imageTaskCount(payload) } @@ -821,7 +1009,7 @@ func normalizedImageOutputStatuses(mode string, count int, value any) []string { status := "queued" if index < len(source) { switch source[index] { - case "queued", "running", "success": + case TaskStatusQueued, TaskStatusRunning, TaskStatusSuccess, TaskStatusError, TaskStatusCancelled: status = source[index] } } @@ -831,12 +1019,63 @@ func normalizedImageOutputStatuses(mode string, count int, value any) []string { } func hasImageTaskOutputData(item map[string]any) bool { + if item == nil { + return false + } + return util.Clean(item["b64_json"]) != "" || util.Clean(item["url"]) != "" || util.Clean(item["text_response"]) != "" +} + +func hasBillableImageTaskOutputData(item map[string]any) bool { if item == nil { return false } return util.Clean(item["b64_json"]) != "" || util.Clean(item["url"]) != "" } +func billableTaskOutputCount(task map[string]any) int { + if task == nil || util.Clean(task["output_type"]) == "text" { + return 0 + } + count := 0 + for _, item := range util.AsMapSlice(task["data"]) { + if hasBillableImageTaskOutputData(item) { + count++ + } + } + return count +} + +func isBillableImageTaskMode(mode string, payload map[string]any) bool { + if mode == "generate" || mode == "edit" { + return true + } + return mode == "chat" && util.ToBool(payload[imageTaskBillingBillablePayloadKey]) +} + +func creationTaskBillingEndpoint(mode string) string { + switch mode { + case "edit": + return "/api/creation-tasks/image-edits" + case "chat": + return "/api/creation-tasks/chat-completions" + default: + return "/api/creation-tasks/image-generations" + } +} + +func imageTaskBillingChargeKeyFor(owner, taskID, scope string) string { + return strings.Join([]string{"task", strings.TrimSpace(owner), strings.TrimSpace(taskID), strings.TrimSpace(scope)}, ":") +} + +func imageTaskBillingReference(mode, taskID, model, chargeKey string) BillingReference { + return BillingReference{ + Endpoint: creationTaskBillingEndpoint(mode), + Model: model, + TaskID: taskID, + ChargeKey: chargeKey, + } +} + func mergeImageTaskMetadata(payload map[string]any, metadata map[string]any) { if len(metadata) == 0 { return @@ -847,6 +1086,32 @@ func mergeImageTaskMetadata(payload map[string]any, metadata map[string]any) { if requestedSize := strings.TrimSpace(util.Clean(metadata["requested_size"])); requestedSize != "" { payload["requested_size"] = requestedSize } + if util.ToBool(metadata["share_prompt_parameters"]) { + payload["share_prompt_parameters"] = true + if util.ToBool(metadata["share_reference_images"]) { + payload["share_reference_images"] = true + } + } + if conversationID := util.Clean(metadata["frontend_conversation_id"]); conversationID != "" { + payload["frontend_conversation_id"] = conversationID + } + if fallback := normalizedFallbackReferenceImage(metadata["fallback_reference_image"]); len(fallback) > 0 { + payload["fallback_reference_image"] = fallback + } +} + +func normalizedFallbackReferenceImage(value any) map[string]any { + raw := util.StringMap(value) + if len(raw) == 0 { + return nil + } + fallback := map[string]any{} + for _, key := range []string{"path", "url", "b64_json", "outputFormat"} { + if text := strings.TrimSpace(util.Clean(raw[key])); text != "" { + fallback[key] = text + } + } + return fallback } func mergeImageOutputOptions(payload map[string]any, options ImageOutputOptions) { diff --git a/internal/service/image_task_test.go b/internal/service/image_task_test.go index 312d1005f..37ab7df07 100644 --- a/internal/service/image_task_test.go +++ b/internal/service/image_task_test.go @@ -4,24 +4,22 @@ import ( "context" "encoding/json" "errors" - "os" - "path/filepath" "strings" "sync" "testing" "time" + "chatgpt2api/internal/storage" "chatgpt2api/internal/util" ) func TestImageTaskServiceIdempotencyOwnerIsolationAndCompletion(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handlerCalls := make(chan map[string]any, 4) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { handlerCalls <- payload return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) alice := Identity{ID: "alice", Name: "Alice", Role: "user"} bob := Identity{ID: "bob", Name: "Bob", Role: "user"} @@ -55,13 +53,12 @@ func TestImageTaskServiceIdempotencyOwnerIsolationAndCompletion(t *testing.T) { } func TestImageTaskServiceUsesOwnerIDAroundCredentialRotation(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handlerCalls := make(chan map[string]any, 4) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { handlerCalls <- payload return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) ownerID := "linuxdo:123" oldKey := Identity{ID: ownerID, OwnerID: ownerID, CredentialID: "key-old", Name: "Alice", Role: "user"} newKey := Identity{ID: ownerID, OwnerID: ownerID, CredentialID: "key-new", Name: "Alice", Role: "user"} @@ -86,8 +83,7 @@ func TestImageTaskServiceUsesOwnerIDAroundCredentialRotation(t *testing.T) { } func TestImageTaskServiceListTasksReturnsEmptyArrays(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") - svc := NewImageTaskService(path, failingImageTaskHandler, failingImageTaskHandler, failingImageTaskHandler, func() int { return 30 }) + svc := newTestImageTaskService(t, failingImageTaskHandler, failingImageTaskHandler, failingImageTaskHandler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} for name, got := range map[string]map[string]any{ @@ -121,8 +117,7 @@ func TestImageTaskServiceListTasksReturnsEmptyArrays(t *testing.T) { } func TestImageTaskServiceRejectsBlankPromptBeforeQueueing(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") - svc := NewImageTaskService(path, failingImageTaskHandler, failingImageTaskHandler, failingImageTaskHandler, func() int { return 30 }) + svc := newTestImageTaskService(t, failingImageTaskHandler, failingImageTaskHandler, failingImageTaskHandler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} for name, submit := range map[string]func() (map[string]any, error){ @@ -133,7 +128,7 @@ func TestImageTaskServiceRejectsBlankPromptBeforeQueueing(t *testing.T) { return svc.SubmitEdit(context.Background(), identity, "task-2", "\t", "gpt-image-2", "1024x1024", "high", "https://base.test", []any{"image"}, 1, nil) }, "chat": func() (map[string]any, error) { - return svc.SubmitChat(context.Background(), identity, "task-3", " ", "auto", []map[string]any{{"role": "user", "content": "hello"}}) + return svc.SubmitChat(context.Background(), identity, "task-3", " ", "auto", []map[string]any{{"role": "user", "content": "hello"}}, false) }, } { t.Run(name, func(t *testing.T) { @@ -150,13 +145,12 @@ func TestImageTaskServiceRejectsBlankPromptBeforeQueueing(t *testing.T) { } func TestImageTaskServicePassesMessagesToHandler(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handlerCalls := make(chan map[string]any, 1) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { handlerCalls <- payload return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} messages := []any{ map[string]any{"role": "user", "content": "你好,你是什么模型?"}, @@ -187,16 +181,21 @@ func TestImageTaskServicePassesMessagesToHandler(t *testing.T) { } func TestImageTaskServicePassesImageRequestMetadataToHandler(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handlerCalls := make(chan map[string]any, 1) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { handlerCalls <- payload return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} - if _, err := svc.SubmitGenerationWithMetadata(context.Background(), identity, "task-1", "draw", "gpt-image-2", "2048x2048", "high", "https://base.test", 1, nil, map[string]any{"image_resolution": "2k", "requested_size": "2048x2048"}); err != nil { + metadata := map[string]any{ + "image_resolution": "2k", + "requested_size": "2048x2048", + "frontend_conversation_id": "front-1", + "fallback_reference_image": map[string]any{"path": "images/owner/result.png", "url": "", "b64_json": "abc", "outputFormat": "png"}, + } + if _, err := svc.SubmitGenerationWithMetadata(context.Background(), identity, "task-1", "draw", "gpt-image-2", "2048x2048", "high", "https://base.test", 1, nil, metadata); err != nil { t.Fatalf("SubmitGenerationWithMetadata() error = %v", err) } @@ -208,6 +207,13 @@ func TestImageTaskServicePassesImageRequestMetadataToHandler(t *testing.T) { if got := payload["requested_size"]; got != "2048x2048" { t.Fatalf("payload requested_size = %#v, want 2048x2048 in %#v", got, payload) } + if got := payload["frontend_conversation_id"]; got != "front-1" { + t.Fatalf("payload frontend_conversation_id = %#v, want front-1 in %#v", got, payload) + } + fallback := util.StringMap(payload["fallback_reference_image"]) + if fallback["path"] != "images/owner/result.png" || fallback["b64_json"] != "abc" || fallback["outputFormat"] != "png" { + t.Fatalf("payload fallback_reference_image = %#v", payload["fallback_reference_image"]) + } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for handler payload") } @@ -215,13 +221,12 @@ func TestImageTaskServicePassesImageRequestMetadataToHandler(t *testing.T) { } func TestImageTaskServicePassesImageToolOptionsToHandler(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handlerCalls := make(chan map[string]any, 1) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { handlerCalls <- payload return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} partialImages := 2 @@ -243,7 +248,6 @@ func TestImageTaskServicePassesImageToolOptionsToHandler(t *testing.T) { } func TestImageTaskServiceSubmitsChatTasks(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handlerCalls := make(chan map[string]any, 1) imageHandler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil @@ -252,11 +256,11 @@ func TestImageTaskServiceSubmitsChatTasks(t *testing.T) { handlerCalls <- payload return map[string]any{"output_type": "text", "data": []map[string]any{{"text_response": "chat response"}}}, nil } - svc := NewImageTaskService(path, imageHandler, imageHandler, chatHandler, func() int { return 30 }) + svc := newTestImageTaskService(t, imageHandler, imageHandler, chatHandler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} messages := []map[string]any{{"role": "user", "content": "hello"}} - if _, err := svc.SubmitChat(context.Background(), identity, "chat-1", "hello", "auto", messages); err != nil { + if _, err := svc.SubmitChat(context.Background(), identity, "chat-1", "hello", "auto", messages, false); err != nil { t.Fatalf("SubmitChat() error = %v", err) } waitForTaskStatus(t, svc, identity, "chat-1", TaskStatusSuccess) @@ -283,7 +287,6 @@ func TestImageTaskServiceSubmitsChatTasks(t *testing.T) { } func TestImageTaskServiceDoesNotLimitGlobalImageSlots(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") started := make(chan string, 2) release := make(chan struct{}) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { @@ -291,7 +294,7 @@ func TestImageTaskServiceDoesNotLimitGlobalImageSlots(t *testing.T) { <-release return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "first", "gpt-image-2", "1024x1024", "high", "https://base.test", 4, nil); err != nil { @@ -312,7 +315,6 @@ func TestImageTaskServiceDoesNotLimitGlobalImageSlots(t *testing.T) { } func TestImageTaskServicePublishesPartialImageDataWhileRunning(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") partialPublished := make(chan struct{}) release := make(chan struct{}) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { @@ -331,7 +333,7 @@ func TestImageTaskServicePublishesPartialImageDataWhileRunning(t *testing.T) { {"url": "https://example.test/second.png"}, }}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: AuthRoleUser} if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 2, nil); err != nil { @@ -350,7 +352,6 @@ func TestImageTaskServicePublishesPartialImageDataWhileRunning(t *testing.T) { } func TestImageTaskServiceLimitsUserDefaultConcurrentCreationUnits(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") startedImages := make(chan int, 3) release := make(chan struct{}) var mu sync.Mutex @@ -407,7 +408,7 @@ func TestImageTaskServiceLimitsUserDefaultConcurrentCreationUnits(t *testing.T) chatHandler := func(context.Context, Identity, map[string]any) (map[string]any, error) { return map[string]any{"output_type": "text", "data": []map[string]any{{"text_response": "chat response"}}}, nil } - svc := NewImageTaskService(path, imageHandler, imageHandler, chatHandler, func() int { return 30 }, func() int { return 2 }) + svc := newTestImageTaskService(t, imageHandler, imageHandler, chatHandler, func() int { return 30 }, func() int { return 2 }) alice := Identity{ID: "alice", Name: "Alice", Role: AuthRoleUser} if _, err := svc.SubmitGeneration(context.Background(), alice, "task-1", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 3, nil); err != nil { @@ -438,8 +439,6 @@ func TestImageTaskServiceLimitsUserDefaultConcurrentCreationUnits(t *testing.T) if len(seen) != 3 { t.Fatalf("started image indexes after release = %#v, want three images", seen) } - - path = filepath.Join(t.TempDir(), "image_tasks.json") started := make(chan string, 3) releaseImage := make(chan struct{}) releaseChat := make(chan struct{}) @@ -487,7 +486,7 @@ func TestImageTaskServiceLimitsUserDefaultConcurrentCreationUnits(t *testing.T) } return map[string]any{"output_type": "text", "data": []map[string]any{{"text_response": "chat response"}}}, nil } - svc = NewImageTaskService(path, imageHandler, imageHandler, chatHandler, func() int { return 30 }, func() int { return 2 }) + svc = newTestImageTaskService(t, imageHandler, imageHandler, chatHandler, func() int { return 30 }, func() int { return 2 }) messages := []map[string]any{{"role": "user", "content": "hello"}} if _, err := svc.SubmitEdit(context.Background(), alice, "edit-1", "edit", "gpt-image-2", "1024x1024", "high", "https://base.test", []any{"image"}, 2, nil); err != nil { @@ -499,7 +498,7 @@ func TestImageTaskServiceLimitsUserDefaultConcurrentCreationUnits(t *testing.T) if got := waitForStartedTask(t, started); got != "image" { t.Fatalf("started task = %q, want image", got) } - if _, err := svc.SubmitChat(context.Background(), alice, "chat-1", "hello", "auto", messages); err != nil { + if _, err := svc.SubmitChat(context.Background(), alice, "chat-1", "hello", "auto", messages, false); err != nil { t.Fatalf("SubmitChat(chat-1) error = %v", err) } waitForTaskStatus(t, svc, alice, "chat-1", TaskStatusQueued) @@ -519,11 +518,10 @@ func TestImageTaskServiceLimitsUserDefaultConcurrentCreationUnits(t *testing.T) } func TestImageTaskServiceLimitsUserDefaultRPM(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }, nil, func() int { return 1 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }, nil, func() int { return 1 }) user := Identity{ID: "alice", Name: "Alice", Role: AuthRoleUser} admin := Identity{ID: "admin", Name: "Admin", Role: AuthRoleAdmin} @@ -550,7 +548,6 @@ func TestImageTaskServiceLimitsUserDefaultRPM(t *testing.T) { } func TestImageTaskServiceCancelsRunningTask(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") started := make(chan struct{}) handlerDone := make(chan error, 1) handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { @@ -559,7 +556,7 @@ func TestImageTaskServiceCancelsRunningTask(t *testing.T) { handlerDone <- ctx.Err() return nil, ctx.Err() } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil); err != nil { @@ -590,11 +587,10 @@ func TestImageTaskServiceCancelsRunningTask(t *testing.T) { } func TestImageTaskServicePreservesPartialDataOnFailure(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { return map[string]any{"data": []map[string]any{{"url": "https://example.test/first.png"}}}, errors.New("second image failed") } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 2, nil); err != nil { @@ -610,15 +606,343 @@ func TestImageTaskServicePreservesPartialDataOnFailure(t *testing.T) { if item["error"] != "second image failed" { t.Fatalf("partial failure error = %#v", item) } + statuses := util.AsStringSlice(item["output_statuses"]) + if len(statuses) != 2 || statuses[0] != "success" || statuses[1] != "error" { + t.Fatalf("output_statuses = %#v, want partial success and failed remainder", statuses) + } +} + +func TestImageTaskServiceBillingSuccessFailureCancelAndTextOutput(t *testing.T) { + operator := Identity{ID: "admin", Name: "Admin", Role: AuthRoleAdmin} + user := Identity{ID: "alice", Name: "Alice", Role: AuthRoleUser} + newBilling := func(t *testing.T, defaults testBillingDefaults) *BillingService { + t.Helper() + billing := newTestBillingService(t, defaults) + billing.InitializeUserDefaults("alice") + return billing + } + + t.Run("partial success consumes actual outputs", func(t *testing.T) { + svc := newTestImageTaskService(t, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + return map[string]any{"data": []map[string]any{ + {"url": "https://example.test/first.png"}, + {"url": "https://example.test/second.png"}, + }}, nil + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 4}) + svc.SetBillingService(billing) + if _, err := svc.SubmitGeneration(context.Background(), user, "success", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 4, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + waitForTaskStatus(t, svc, user, "success", TaskStatusSuccess) + got := svc.ListTasks(user, []string{"success"}) + item := got["items"].([]map[string]any)[0] + if util.ToInt(item["billing_consumed_amount"], -1) != 2 { + t.Fatalf("task billing = %#v", item) + } + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 2 || util.ToInt(standard["lifetime_consumed"], -1) != 2 || util.ToInt(state["available"], -1) != 2 { + t.Fatalf("billing state after partial success = %#v", state) + } + }) + + t.Run("handler failure consumes zero", func(t *testing.T) { + svc := newTestImageTaskService(t, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + return map[string]any{"data": []map[string]any{{"url": "https://example.test/first.png"}}}, errors.New("upstream failed") + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 2}) + svc.SetBillingService(billing) + if _, err := svc.SubmitGeneration(context.Background(), user, "failed", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 2, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + waitForTaskStatus(t, svc, user, "failed", TaskStatusError) + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 2 || util.ToInt(standard["lifetime_consumed"], -1) != 0 { + t.Fatalf("billing state after failure = %#v", state) + } + }) + + t.Run("cancel consumes zero", func(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + svc := newTestImageTaskService(t, + func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { + close(started) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 2}) + svc.SetBillingService(billing) + if _, err := svc.SubmitGeneration(context.Background(), user, "cancel", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 2, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for task start") + } + cancelled, err := svc.CancelTask(user, "cancel") + if err != nil { + t.Fatalf("CancelTask() error = %v", err) + } + close(release) + if cancelled["status"] != TaskStatusCancelled { + t.Fatalf("cancelled task = %#v", cancelled) + } + got := svc.ListTasks(user, []string{"cancel"}) + item := got["items"].([]map[string]any)[0] + if util.ToInt(item["billing_consumed_amount"], -1) != 0 { + t.Fatalf("settled cancelled task = %#v", item) + } + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 2 || util.ToInt(standard["lifetime_consumed"], -1) != 0 { + t.Fatalf("billing state after cancel = %#v", state) + } + }) + + t.Run("image task returning text consumes zero", func(t *testing.T) { + svc := newTestImageTaskService(t, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + return map[string]any{"message": "text response", "output_type": "text"}, nil + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 1}) + svc.SetBillingService(billing) + if _, err := svc.SubmitGeneration(context.Background(), user, "text", "who are you", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + waitForTaskStatus(t, svc, user, "text", TaskStatusSuccess) + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 1 || util.ToInt(standard["lifetime_consumed"], -1) != 0 { + t.Fatalf("billing state after text output = %#v", state) + } + }) + + t.Run("subscription task consumes used quota", func(t *testing.T) { + svc := newTestImageTaskService(t, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 0}) + if _, err := billing.ApplyAdjustment("alice", operator, map[string]any{"type": "switch_to_subscription", "quota_limit": 2, "quota_period": BillingPeriodMonthly, "reason": "test"}); err != nil { + t.Fatalf("switch_to_subscription error = %v", err) + } + svc.SetBillingService(billing) + if _, err := svc.SubmitGeneration(context.Background(), user, "subscription", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 2, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + waitForTaskStatus(t, svc, user, "subscription", TaskStatusSuccess) + state := billing.Get("alice") + sub := util.StringMap(state["subscription"]) + if util.ToInt(sub["quota_used"], -1) != 1 || util.ToInt(state["available"], -1) != 1 { + t.Fatalf("billing state after subscription task = %#v", state) + } + }) + + t.Run("precharge protects running task from delivery-time drain", func(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + svc := newTestImageTaskService(t, + func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { + close(started) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return map[string]any{"data": []map[string]any{ + {"url": "https://example.test/first.png"}, + {"url": "https://example.test/second.png"}, + }}, nil + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 3}) + svc.SetBillingService(billing) + if _, err := svc.SubmitGeneration(context.Background(), user, "delivery-drain-protected", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 2, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for task start") + } + if err := billing.Charge(user, 1, BillingReference{ChargeKey: "external:drain:partial"}); err != nil { + t.Fatalf("external Charge() error = %v", err) + } + close(release) + waitForTaskStatus(t, svc, user, "delivery-drain-protected", TaskStatusSuccess) + got := svc.ListTasks(user, []string{"delivery-drain-protected"}) + item := got["items"].([]map[string]any)[0] + data := item["data"].([]map[string]any) + if len(data) != 2 || data[0]["url"] != "https://example.test/first.png" || data[1]["url"] != "https://example.test/second.png" { + t.Fatalf("task lost prepaid outputs = %#v", item) + } + if util.ToInt(item["billing_consumed_amount"], -1) != 2 { + t.Fatalf("task billing = %#v", item) + } + statuses := util.AsStringSlice(item["output_statuses"]) + if len(statuses) != 2 || statuses[0] != TaskStatusSuccess || statuses[1] != TaskStatusSuccess { + t.Fatalf("output_statuses = %#v, want both prepaid outputs successful", statuses) + } + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 3 || util.ToInt(state["available"], -1) != 0 { + t.Fatalf("billing state after delivery-time drain = %#v", state) + } + }) + + t.Run("insufficient balance rejects before queueing", func(t *testing.T) { + handlerCalled := false + svc := newTestImageTaskService(t, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + handlerCalled = true + return map[string]any{"data": []map[string]any{{"url": "https://example.test/unpaid.png"}}}, nil + }, + failingImageTaskHandler, + failingImageTaskHandler, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 0}) + svc.SetBillingService(billing) + _, err := svc.SubmitGeneration(context.Background(), user, "delivery-drain-empty", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil) + var limitErr BillingLimitError + if !errors.As(err, &limitErr) || limitErr.Code != "user_balance_insufficient" { + t.Fatalf("SubmitGeneration() error = %#v", err) + } + if handlerCalled { + t.Fatal("handler was called for rejected image task") + } + got := svc.ListTasks(user, []string{"delivery-drain-empty"}) + if len(got["items"].([]map[string]any)) != 0 || len(got["missing_ids"].([]string)) != 1 { + t.Fatalf("rejected image task should not be queued: %#v", got) + } + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 0 || util.ToInt(standard["lifetime_consumed"], -1) != 0 { + t.Fatalf("billing state after rejected task = %#v", state) + } + }) +} + +func TestImageTaskServiceBillingChatEquivalenceClasses(t *testing.T) { + user := Identity{ID: "alice", Name: "Alice", Role: AuthRoleUser} + messages := []map[string]any{{"role": "user", "content": "hello"}} + newBilling := func(t *testing.T, defaults testBillingDefaults) *BillingService { + t.Helper() + billing := newTestBillingService(t, defaults) + billing.InitializeUserDefaults("alice") + return billing + } + + t.Run("pure text chat does not require billing", func(t *testing.T) { + svc := newTestImageTaskService(t, + failingImageTaskHandler, + failingImageTaskHandler, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + return map[string]any{"output_type": "text", "data": []map[string]any{{"text_response": "hello"}}}, nil + }, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{}) + svc.SetBillingService(billing) + if _, err := svc.SubmitChat(context.Background(), user, "text-chat", "hello", "auto", messages, false); err != nil { + t.Fatalf("SubmitChat() error = %v", err) + } + waitForTaskStatus(t, svc, user, "text-chat", TaskStatusSuccess) + state := billing.Get("alice") + if util.ToInt(state["available"], -1) != 0 { + t.Fatalf("text chat should not change default zero billing state = %#v", state) + } + }) + + t.Run("billable chat consumes actual image outputs", func(t *testing.T) { + svc := newTestImageTaskService(t, + failingImageTaskHandler, + failingImageTaskHandler, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil + }, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 2}) + svc.SetBillingService(billing) + if _, err := svc.SubmitChat(context.Background(), user, "image-chat", "draw", "auto", messages, true, 2); err != nil { + t.Fatalf("SubmitChat() error = %v", err) + } + waitForTaskStatus(t, svc, user, "image-chat", TaskStatusSuccess) + state := billing.Get("alice") + standard := util.StringMap(state["standard"]) + if util.ToInt(standard["balance"], -1) != 1 || util.ToInt(standard["lifetime_consumed"], -1) != 1 { + t.Fatalf("image chat billing = %#v", state) + } + }) + + t.Run("billable chat insufficient balance rejects before queueing", func(t *testing.T) { + handlerCalled := false + svc := newTestImageTaskService(t, + failingImageTaskHandler, + failingImageTaskHandler, + func(context.Context, Identity, map[string]any) (map[string]any, error) { + handlerCalled = true + return map[string]any{"data": []map[string]any{{"url": "https://example.test/image.png"}}}, nil + }, + func() int { return 30 }, + ) + billing := newBilling(t, testBillingDefaults{standardBalance: 1}) + svc.SetBillingService(billing) + _, err := svc.SubmitChat(context.Background(), user, "image-chat-rejected", "draw", "auto", messages, true, 2) + var limitErr BillingLimitError + if !errors.As(err, &limitErr) || limitErr.Code != "user_balance_insufficient" { + t.Fatalf("SubmitChat() error = %#v", err) + } + if handlerCalled { + t.Fatal("handler was called for rejected billable chat") + } + got := svc.ListTasks(user, []string{"image-chat-rejected"}) + if len(got["items"].([]map[string]any)) != 0 || len(got["missing_ids"].([]string)) != 1 { + t.Fatalf("rejected billable chat should not be queued: %#v", got) + } + }) } func TestImageTaskServiceMarksTimedOutTaskAsError(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { <-ctx.Done() return nil, ctx.Err() } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) svc.SetTaskTimeoutGetter(func() time.Duration { return 20 * time.Millisecond }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} @@ -634,11 +958,32 @@ func TestImageTaskServiceMarksTimedOutTaskAsError(t *testing.T) { } func TestImageTaskServicePreservesTextOutputType(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { return map[string]any{"message": "text response", "output_type": "text"}, nil } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) + identity := Identity{ID: "alice", Name: "Alice", Role: "user"} + + if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "who are you", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + waitForTaskStatus(t, svc, identity, "task-1", TaskStatusSuccess) + got := svc.ListTasks(identity, []string{"task-1"}) + item := got["items"].([]map[string]any)[0] + if item["output_type"] != "text" { + t.Fatalf("output_type = %#v, want text in %#v", item["output_type"], item) + } + data := item["data"].([]map[string]any) + if len(data) != 1 || data[0]["text_response"] != "text response" { + t.Fatalf("text response data = %#v", data) + } +} + +func TestImageTaskServiceStoresTextOutputFromHandlerError(t *testing.T) { + handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { + return map[string]any{"message": "text response", "output_type": "text"}, errors.New("text response") + } + svc := newTestImageTaskService(t, handler, handler, handler, func() int { return 30 }) identity := Identity{ID: "alice", Name: "Alice", Role: "user"} if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "who are you", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil); err != nil { @@ -647,6 +992,9 @@ func TestImageTaskServicePreservesTextOutputType(t *testing.T) { waitForTaskStatus(t, svc, identity, "task-1", TaskStatusSuccess) got := svc.ListTasks(identity, []string{"task-1"}) item := got["items"].([]map[string]any)[0] + if util.Clean(item["error"]) != "" { + t.Fatalf("error = %#v, want empty in %#v", item["error"], item) + } if item["output_type"] != "text" { t.Fatalf("output_type = %#v, want text in %#v", item["output_type"], item) } @@ -654,23 +1002,27 @@ func TestImageTaskServicePreservesTextOutputType(t *testing.T) { if len(data) != 1 || data[0]["text_response"] != "text response" { t.Fatalf("text response data = %#v", data) } + statuses := item["output_statuses"].([]string) + if len(statuses) != 1 || statuses[0] != "success" { + t.Fatalf("output_statuses = %#v, want success", statuses) + } } func TestImageTaskServiceRestoresUnfinishedTasksAsErrors(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") + backend := newTestStorageBackend(t) raw := map[string]any{"tasks": []map[string]any{ {"id": "queued", "owner_id": "alice", "status": TaskStatusQueued, "mode": "generate", "created_at": "2026-01-01 00:00:00", "updated_at": "2026-01-01 00:00:00"}, {"id": "running", "owner_id": "alice", "status": TaskStatusRunning, "mode": "edit", "created_at": "2026-01-01 00:00:00", "updated_at": "2026-01-01 00:00:00"}, }} - data, err := json.Marshal(raw) - if err != nil { - t.Fatalf("Marshal() error = %v", err) + store, ok := backend.(storage.JSONDocumentBackend) + if !ok { + t.Fatalf("storage backend %T does not implement JSONDocumentBackend", backend) } - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) + if err := store.SaveJSONDocument("image_tasks.json", raw); err != nil { + t.Fatalf("SaveJSONDocument() error = %v", err) } - svc := NewImageTaskService(path, failingImageTaskHandler, failingImageTaskHandler, failingImageTaskHandler, func() int { return 30 }) + svc := NewStoredImageTaskService(backend, failingImageTaskHandler, failingImageTaskHandler, failingImageTaskHandler, func() int { return 30 }) got := svc.ListTasks(Identity{ID: "alice"}, []string{"queued", "running"}) items := got["items"].([]map[string]any) if len(items) != 2 { @@ -686,6 +1038,11 @@ func TestImageTaskServiceRestoresUnfinishedTasksAsErrors(t *testing.T) { } } +func newTestImageTaskService(t *testing.T, generation ImageTaskHandler, edit ImageTaskHandler, chat ImageTaskHandler, retentionGetter func() int, limitGetters ...func() int) *ImageTaskService { + t.Helper() + return NewStoredImageTaskService(newTestStorageBackend(t), generation, edit, chat, retentionGetter, limitGetters...) +} + func waitForStartedTask(t *testing.T, started <-chan string) string { t.Helper() select { diff --git a/internal/service/image_test.go b/internal/service/image_test.go index 7574d53bf..b6d21d2ea 100644 --- a/internal/service/image_test.go +++ b/internal/service/image_test.go @@ -37,9 +37,9 @@ func (c testImageConfig) ImageMetadataDir() string { return path } -func (c testImageConfig) CleanupOldImages() int { - return 0 -} +func (c testImageConfig) ImageRetentionDays() int { return 30 } + +func (c testImageConfig) ImageStorageLimitBytes() int64 { return 0 } var allImages = ImageAccessScope{All: true} @@ -492,6 +492,271 @@ func TestImageServiceListImagesReturnsRequestedResolutionPreset(t *testing.T) { } } +func TestImageServiceListImagesReturnsGenerationReuseMetadata(t *testing.T) { + root := t.TempDir() + config := testImageConfig{root: root} + rel := "2026/04/29/reusable.png" + path := filepath.Join(config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeTestPNG(path); err != nil { + t.Fatalf("writeTestPNG() error = %v", err) + } + + outputCompression := 42 + partialImages := 2 + service := NewImageService(config) + service.RecordGeneratedImages([]string{rel}, "linuxdo:123", "alice", ImageVisibilityPublic, GeneratedImageMetadata{ + Prompt: "draw a reusable image", + Model: "gpt-image-2", + Quality: "high", + ResolutionPreset: "2k", + RequestedSize: "2048x2048", + OutputFormat: "jpeg", + OutputCompression: &outputCompression, + Background: "transparent", + Moderation: "low", + Style: "vivid", + PartialImages: &partialImages, + InputImageMask: "mask-id", + ReferenceImages: []GeneratedImageReference{ + {Filename: "原始参考图.png", ContentType: "image/png", Data: []byte("reference-bytes")}, + }, + SharePromptParams: true, + ShareReferences: true, + }) + + list := service.ListImages("http://127.0.0.1:8000", "", "", ImageAccessScope{Public: true}) + items := list["items"].([]map[string]any) + if len(items) != 1 { + t.Fatalf("ListImages() = %#v", list) + } + item := items[0] + if item["prompt"] != "draw a reusable image" || + item["model"] != "gpt-image-2" || + item["quality"] != "high" || + item["resolution_preset"] != "2k" || + item["requested_size"] != "2048x2048" || + item["output_format"] != "jpeg" || + item["output_compression"] != 42 || + item["background"] != "transparent" || + item["moderation"] != "low" || + item["style"] != "vivid" || + item["partial_images"] != 2 || + item["input_image_mask"] != "mask-id" { + t.Fatalf("reuse metadata = %#v", item) + } + referenceURLs, ok := item["reference_image_urls"].([]string) + if !ok || len(referenceURLs) != 1 || !strings.Contains(referenceURLs[0], "/image-references/") { + t.Fatalf("reference_image_urls = %#v", item["reference_image_urls"]) + } + referenceItems, ok := item["reference_images"].([]map[string]any) + if !ok || len(referenceItems) != 1 || referenceItems[0]["url"] != referenceURLs[0] { + t.Fatalf("reference_images = %#v", item["reference_images"]) + } + access, err := service.ImageReferenceFileAccess(referenceURLs[0]) + if err != nil { + t.Fatalf("ImageReferenceFileAccess() error = %v", err) + } + if access.SourceRel != rel || access.ContentType != "image/png" { + t.Fatalf("reference access = %#v", access) + } + data, err := os.ReadFile(access.Path) + if err != nil { + t.Fatalf("ReadFile(reference) error = %v", err) + } + if string(data) != "reference-bytes" { + t.Fatalf("reference data = %q", data) + } + if _, err := service.DeleteImages([]string{rel}, ImageAccessScope{OwnerID: "linuxdo:123"}); err != nil { + t.Fatalf("DeleteImages() error = %v", err) + } + if _, err := os.Stat(access.Path); !os.IsNotExist(err) { + t.Fatalf("reference path still exists or stat failed unexpectedly: %v", err) + } +} + +func TestImageServicePublicListHidesUnsharedGenerationMetadata(t *testing.T) { + root := t.TempDir() + config := testImageConfig{root: root} + rel := "2026/04/29/unshared.png" + path := filepath.Join(config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeTestPNG(path); err != nil { + t.Fatalf("writeTestPNG() error = %v", err) + } + + service := NewImageService(config) + service.RecordGeneratedImages([]string{rel}, "linuxdo:123", "alice", ImageVisibilityPublic, GeneratedImageMetadata{ + Prompt: "private recipe", + ReferenceImages: []GeneratedImageReference{ + {Filename: "source.png", ContentType: "image/png", Data: []byte("reference-bytes")}, + }, + }) + + publicList := service.ListImages("http://127.0.0.1:8000", "", "", ImageAccessScope{Public: true}) + publicItems := publicList["items"].([]map[string]any) + if len(publicItems) != 1 { + t.Fatalf("public ListImages() = %#v", publicList) + } + if publicItems[0]["prompt"] != nil || publicItems[0]["reference_image_urls"] != nil { + t.Fatalf("public item exposed unshared metadata = %#v", publicItems[0]) + } + + ownerList := service.ListImages("http://127.0.0.1:8000", "", "", ImageAccessScope{OwnerID: "linuxdo:123"}) + ownerItems := ownerList["items"].([]map[string]any) + if len(ownerItems) != 1 || ownerItems[0]["prompt"] != "private recipe" || ownerItems[0]["reference_image_urls"] == nil { + t.Fatalf("owner item did not include private metadata = %#v", ownerList) + } +} + +func TestImageServiceCleanupStorageClearsThumbnailCacheOnly(t *testing.T) { + root := t.TempDir() + config := testImageConfig{root: root} + rel := "2026/04/29/sample.png" + imagePath := filepath.Join(config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeTestPNG(imagePath); err != nil { + t.Fatalf("writeTestPNG() error = %v", err) + } + + service := NewImageService(config) + service.RecordGeneratedImages([]string{rel}, "linuxdo:123", "alice", ImageVisibilityPrivate) + service.EnsureThumbnails([]string{rel}) + thumbPath := filepath.Join(config.ImageThumbnailsDir(), filepath.FromSlash(rel)+thumbnailExtension) + if _, err := os.Stat(thumbPath); err != nil { + t.Fatalf("thumbnail was not created: %v", err) + } + + result, err := service.CleanupStorage(ImageStorageCleanupOptions{ClearThumbnails: true}) + if err != nil { + t.Fatalf("CleanupStorage(thumbnails) error = %v", err) + } + if result.DeletedThumbnails != 1 || result.DeletedImages != 0 { + t.Fatalf("CleanupStorage(thumbnails) = %#v", result) + } + if _, err := os.Stat(imagePath); err != nil { + t.Fatalf("image should remain after thumbnail cleanup: %v", err) + } + if _, err := os.Stat(thumbPath); !os.IsNotExist(err) { + t.Fatalf("thumbnail still exists, stat error = %v", err) + } + list := service.ListImages("http://127.0.0.1:8000", "", "", ImageAccessScope{OwnerID: "linuxdo:123"}) + if items := list["items"].([]map[string]any); len(items) != 1 || items[0]["path"] != rel { + t.Fatalf("image missing after thumbnail cleanup: %#v", list) + } +} + +func TestImageServiceCleanupStorageRetentionRemovesImageGroup(t *testing.T) { + root := t.TempDir() + config := testImageConfig{root: root} + rel := "2026/04/29/old.png" + imagePath := filepath.Join(config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeTestPNG(imagePath); err != nil { + t.Fatalf("writeTestPNG() error = %v", err) + } + + service := NewImageService(config) + service.RecordGeneratedImages([]string{rel}, "linuxdo:123", "alice", ImageVisibilityPrivate, GeneratedImageMetadata{ + ReferenceImages: []GeneratedImageReference{{Filename: "ref.png", ContentType: "image/png", Data: []byte("reference-bytes")}}, + }) + service.EnsureThumbnails([]string{rel}) + thumbPath := filepath.Join(config.ImageThumbnailsDir(), filepath.FromSlash(rel)+thumbnailExtension) + metaPath := filepath.Join(config.ImageMetadataDir(), filepath.FromSlash(rel)+".json") + refDir := filepath.Join(config.ImageMetadataDir(), "references", filepath.FromSlash(rel+".refs")) + old := time.Now().Add(-72 * time.Hour) + for _, path := range []string{imagePath, thumbPath, thumbPath + ".json", metaPath} { + if err := os.Chtimes(path, old, old); err != nil { + t.Fatalf("Chtimes(%s) error = %v", path, err) + } + } + + result, err := service.CleanupStorage(ImageStorageCleanupOptions{RetentionDays: 1}) + if err != nil { + t.Fatalf("CleanupStorage(retention) error = %v", err) + } + if result.DeletedImages != 1 || result.DeletedThumbnails != 1 || result.DeletedReferenceFiles != 1 { + t.Fatalf("CleanupStorage(retention) = %#v", result) + } + for _, path := range []string{imagePath, thumbPath, thumbPath + ".json", metaPath, refDir} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("%s still exists or stat failed unexpectedly: %v", path, err) + } + } +} + +func TestImageServiceCleanupStorageLimitPreservesPublicByDefault(t *testing.T) { + root := t.TempDir() + config := testImageConfig{root: root} + publicRel := "2026/04/29/public.png" + privateRel := "2026/04/29/private.png" + for _, rel := range []string{publicRel, privateRel} { + path := filepath.Join(config.ImagesDir(), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeLargeTestPNG(path); err != nil { + t.Fatalf("writeLargeTestPNG(%s) error = %v", rel, err) + } + } + + service := NewImageService(config) + service.RecordGeneratedImages([]string{publicRel}, "linuxdo:123", "alice", ImageVisibilityPublic) + service.RecordGeneratedImages([]string{privateRel}, "linuxdo:123", "alice", ImageVisibilityPrivate) + summary := service.StorageGovernance() + if summary.ImagesCount != 2 || summary.PublicImagesCount != 1 || summary.PrivateImagesCount != 1 { + t.Fatalf("StorageGovernance() = %#v", summary) + } + + result, err := service.CleanupStorage(ImageStorageCleanupOptions{MaxBytes: summary.TotalBytes - 1}) + if err != nil { + t.Fatalf("CleanupStorage(quota) error = %v", err) + } + if result.DeletedImages != 1 { + t.Fatalf("CleanupStorage(quota) = %#v", result) + } + if _, err := os.Stat(filepath.Join(config.ImagesDir(), filepath.FromSlash(privateRel))); !os.IsNotExist(err) { + t.Fatalf("private image should be deleted, stat error = %v", err) + } + if _, err := os.Stat(filepath.Join(config.ImagesDir(), filepath.FromSlash(publicRel))); err != nil { + t.Fatalf("public image should remain, stat error = %v", err) + } +} + +func TestImageServiceCleanupStorageLimitCanIncludePublic(t *testing.T) { + root := t.TempDir() + config := testImageConfig{root: root} + publicRel := "2026/04/29/public.png" + path := filepath.Join(config.ImagesDir(), filepath.FromSlash(publicRel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := writeLargeTestPNG(path); err != nil { + t.Fatalf("writeLargeTestPNG() error = %v", err) + } + + service := NewImageService(config) + service.RecordGeneratedImages([]string{publicRel}, "linuxdo:123", "alice", ImageVisibilityPublic) + result, err := service.CleanupStorage(ImageStorageCleanupOptions{MaxBytes: 1, IncludePublic: true}) + if err != nil { + t.Fatalf("CleanupStorage(include public) error = %v", err) + } + if result.DeletedImages != 1 { + t.Fatalf("CleanupStorage(include public) = %#v", result) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("public image should be deleted when include_public=true, stat error = %v", err) + } +} + func TestImageServiceDeleteImagesRejectsTraversal(t *testing.T) { root := t.TempDir() outsidePath := filepath.Join(root, "outside.png") diff --git a/internal/service/log.go b/internal/service/log.go index 386738558..5d8d1e19d 100644 --- a/internal/service/log.go +++ b/internal/service/log.go @@ -1,12 +1,12 @@ package service import ( - "bufio" "context" - "encoding/json" "errors" + "fmt" "io" "log/slog" + "net/http" "os" "path/filepath" "strings" @@ -18,15 +18,25 @@ import ( ) const ( - LogTypeEvent = "event" + LogTypeEvent = "event" + LogViewAll = "all" + LogViewMeaningful = "meaningful" + LogViewBusiness = "business" ) type LogService struct { - mu sync.Mutex - path string - store storage.LogBackend + mu sync.Mutex + store storage.LogBackend + usageStatsCache map[string]cachedUserUsageStats } +type cachedUserUsageStats struct { + expiresAt time.Time + stats map[string]map[string]any +} + +const userUsageStatsCacheTTL = 15 * time.Second + type LogQuery struct { Username string Module string @@ -40,6 +50,7 @@ type LogQuery struct { EndDate string StartTime string EndTime string + View string Limit int } @@ -71,10 +82,12 @@ type userUsageAccumulator struct { Daily map[string]*userUsageDay } -func NewLogService(dataDir string, backend ...storage.Backend) *LogService { - path := filepath.Join(dataDir, filepath.FromSlash(storage.LogEventsDocumentName)) - _ = os.MkdirAll(filepath.Dir(path), 0o755) - return &LogService{path: path, store: firstLogStore(backend)} +func NewLogService(backend ...storage.Backend) *LogService { + var store storage.LogBackend + if len(backend) > 0 { + store, _ = backend[0].(storage.LogBackend) + } + return &LogService{store: store} } func (s *LogService) Add(summary string, detail map[string]any) error { @@ -92,19 +105,7 @@ func (s *LogService) Add(summary string, detail map[string]any) error { defer s.mu.Unlock() return s.store.AppendLog(item) } - data, err := json.Marshal(item) - if err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer file.Close() - _, err = file.Write(append(data, '\n')) - return err + return fmt.Errorf("log storage backend is required") } func (s *LogService) List(startDate, endDate string, limit int) []map[string]any { @@ -158,6 +159,35 @@ func (s *LogService) CleanupOlderThan(retentionDays int) (LogCleanupResult, erro }, nil } +func (s *LogService) StartRetentionCleaner(ctx context.Context, retentionGetter func() int, interval time.Duration, logger *Logger) { + if interval <= 0 { + interval = 24 * time.Hour + } + if retentionGetter == nil { + retentionGetter = func() int { return 7 } + } + go func() { + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + result, err := s.CleanupOlderThan(retentionGetter()) + if err != nil { + if logger != nil { + logger.Warning("log retention cleanup failed", "error", err) + } + } else if result.Deleted > 0 && logger != nil { + logger.Info("log retention cleanup completed", "deleted", result.Deleted, "remaining", result.Remaining, "retention_days", result.RetentionDays) + } + timer.Reset(interval) + } + } + }() +} + func (s *LogService) governanceSummaryLocked() LogGovernanceSummary { items, ok := s.loadLogItems("", "") summary := LogGovernanceSummary{} @@ -186,49 +216,7 @@ func (s *LogService) deleteLogsBeforeLocked(day string) (int, error) { return maintenance.DeleteLogsBefore(day) } } - return s.deleteFileLogsBeforeLocked(day) -} - -func (s *LogService) deleteFileLogsBeforeLocked(day string) (int, error) { - day = strings.TrimSpace(day) - if day == "" { - return 0, nil - } - data, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - return 0, nil - } - if err != nil { - return 0, err - } - lines := strings.Split(strings.TrimRight(string(data), "\r\n"), "\n") - kept := make([]string, 0, len(lines)) - removed := 0 - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - var item map[string]any - if json.Unmarshal([]byte(line), &item) == nil { - itemDay := logDay(item) - if itemDay != "" && itemDay < day { - removed++ - continue - } - } - kept = append(kept, line) - } - if removed == 0 { - return 0, nil - } - next := []byte{} - if len(kept) > 0 { - next = []byte(strings.Join(kept, "\n") + "\n") - } - if err := os.WriteFile(s.path, next, 0o644); err != nil { - return 0, err - } - return removed, nil + return 0, fmt.Errorf("log maintenance backend is required") } func (s *LogService) loadLogItems(startDate, endDate string) ([]map[string]any, bool) { @@ -238,28 +226,7 @@ func (s *LogService) loadLogItems(startDate, endDate string) ([]map[string]any, return items, true } } - file, err := os.Open(s.path) - if err != nil { - return nil, false - } - defer file.Close() - var lines []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - out := make([]map[string]any, 0, len(lines)) - for i := len(lines) - 1; i >= 0; i-- { - var item map[string]any - if json.Unmarshal([]byte(lines[i]), &item) != nil { - continue - } - if !matchLogDate(item, startDate, endDate) { - continue - } - out = append(out, item) - } - return out, true + return nil, false } func normalizedLogLimit(limit int) int { @@ -319,7 +286,50 @@ func matchLogQuery(item map[string]any, query LogQuery) bool { if level := strings.TrimSpace(query.LogLevel); level != "" && logLevel(item) != strings.ToLower(level) { return false } - return true + return matchLogView(item, query.View) +} + +func matchLogView(item map[string]any, view string) bool { + switch NormalizeLogView(view, LogViewAll) { + case LogViewBusiness: + return !isAuditLogItem(item) + case LogViewMeaningful: + return isMeaningfulLogItem(item) + default: + return true + } +} + +func NormalizeLogView(value, fallback string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case LogViewAll, LogViewMeaningful, LogViewBusiness: + return strings.ToLower(strings.TrimSpace(value)) + } + if fallback == "" || strings.EqualFold(fallback, value) { + return LogViewMeaningful + } + return NormalizeLogView(fallback, LogViewMeaningful) +} + +func isMeaningfulLogItem(item map[string]any) bool { + if !isAuditLogItem(item) { + return true + } + method := strings.ToUpper(logDetailString(item, "method")) + if method != http.MethodGet && method != http.MethodHead { + return true + } + return logOutcome(item) == "failed" +} + +func isAuditLogItem(item map[string]any) bool { + summary := strings.ToUpper(strings.TrimSpace(util.Clean(item["summary"]))) + method := strings.ToUpper(logDetailString(item, "method")) + path := logDetailString(item, "path") + if method == "" || path == "" { + return false + } + return strings.HasPrefix(summary, method+" ") } func matchLogDate(item map[string]any, startDate, endDate string) bool { @@ -427,11 +437,41 @@ func containsFold(value, filter string) bool { } func (s *LogService) UserUsageStats(days int) map[string]map[string]any { + return cloneUserUsageStats(s.cachedUserUsageStats(days)) +} + +func (s *LogService) UserUsageStatsForUsers(days int, userIDs []string) map[string]map[string]any { + targets := userUsageTargetSet(userIDs) + if len(targets) == 0 { + return map[string]map[string]any{} + } + stats := s.cachedUserUsageStats(days) + out := make(map[string]map[string]any, min(len(targets), len(stats))) + for userID := range targets { + usage := stats[userID] + if usage == nil { + continue + } + out[userID] = cloneUserUsageMap(usage) + } + return out +} + +func (s *LogService) cachedUserUsageStats(days int) map[string]map[string]any { dates := usageDates(days) out := map[string]map[string]any{} if len(dates) == 0 { return out } + cacheKey := userUsageStatsCacheKey(dates) + now := time.Now() + s.mu.Lock() + if cached, ok := s.usageStatsCache[cacheKey]; ok && now.Before(cached.expiresAt) { + s.mu.Unlock() + return cached.stats + } + s.mu.Unlock() + startDate := dates[0] endDate := dates[len(dates)-1] byUser := map[string]*userUsageAccumulator{} @@ -444,24 +484,73 @@ func (s *LogService) UserUsageStats(days int) map[string]map[string]any { for userID, acc := range byUser { out[userID] = userUsageStatsMap(acc, dates) } + s.cacheUserUsageStats(cacheKey, out, now) return out } } - file, err := os.Open(s.path) - if err != nil { - return out + return out +} + +func (s *LogService) cacheUserUsageStats(key string, stats map[string]map[string]any, now time.Time) { + if key == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.usageStatsCache == nil { + s.usageStatsCache = map[string]cachedUserUsageStats{} + } + s.usageStatsCache[key] = cachedUserUsageStats{ + expiresAt: now.Add(userUsageStatsCacheTTL), + stats: stats, + } +} + +func userUsageStatsCacheKey(dates []string) string { + if len(dates) == 0 { + return "" } - defer file.Close() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - var item map[string]any - if json.Unmarshal([]byte(scanner.Text()), &item) != nil { + return dates[0] + "\x00" + dates[len(dates)-1] +} + +func userUsageTargetSet(userIDs []string) map[string]struct{} { + out := map[string]struct{}{} + for _, userID := range userIDs { + userID = util.Clean(userID) + if userID == "" { continue } - accumulateUserUsageLog(byUser, item, startDate, endDate) + out[userID] = struct{}{} } - for userID, acc := range byUser { - out[userID] = userUsageStatsMap(acc, dates) + return out +} + +func cloneUserUsageStats(stats map[string]map[string]any) map[string]map[string]any { + out := make(map[string]map[string]any, len(stats)) + for userID, usage := range stats { + out[userID] = cloneUserUsageMap(usage) + } + return out +} + +func cloneUserUsageMap(usage map[string]any) map[string]any { + out := make(map[string]any, len(usage)) + for key, value := range usage { + if key == "usage_curve" { + if curve, ok := value.([]map[string]any); ok { + nextCurve := make([]map[string]any, 0, len(curve)) + for _, point := range curve { + nextPoint := make(map[string]any, len(point)) + for pointKey, pointValue := range point { + nextPoint[pointKey] = pointValue + } + nextCurve = append(nextCurve, nextPoint) + } + out[key] = nextCurve + continue + } + } + out[key] = value } return out } @@ -739,7 +828,7 @@ func sanitizeLogField(key string, value any) any { func sensitiveLogKey(key string) bool { lower := strings.ToLower(strings.TrimSpace(key)) switch lower { - case "authorization", "password", "secret", "token", "access_token", "refresh_token", "api_key", "key", "dx": + case "authorization", "password", "secret", "token", "access_token", "accesstoken", "refresh_token", "refreshtoken", "session_token", "sessiontoken", "session_json", "sessionjson", "api_key", "key", "dx": return true default: return strings.Contains(lower, "password") || diff --git a/internal/service/log_test.go b/internal/service/log_test.go index 8c922bb57..19715352d 100644 --- a/internal/service/log_test.go +++ b/internal/service/log_test.go @@ -1,27 +1,22 @@ package service import ( - "os" - "path/filepath" + "context" + "reflect" + "strings" "testing" "time" + + "chatgpt2api/internal/util" ) -func TestLogServiceUsesUnifiedLogsDirectory(t *testing.T) { - dir := t.TempDir() - logs := NewLogService(dir) +func TestLogServiceStoresLogsInDatabase(t *testing.T) { + logs := NewLogService(newTestStorageBackend(t)) if err := logs.Add("新增账号", map[string]any{"module": "accounts", "operation_type": "新增", "added": 1}); err != nil { t.Fatalf("Add() error = %v", err) } - if _, err := os.Stat(filepath.Join(dir, "logs", "events.jsonl")); err != nil { - t.Fatalf("expected unified log file under data/logs: %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "logs.jsonl")); !os.IsNotExist(err) { - t.Fatalf("root logs.jsonl should not be used, stat error = %v", err) - } - items := logs.List("", "", 10) if len(items) != 1 { t.Fatalf("List() length = %d, want 1", len(items)) @@ -35,8 +30,7 @@ func TestLogServiceUsesUnifiedLogsDirectory(t *testing.T) { } func TestLogServiceSearchFiltersUnifiedLogs(t *testing.T) { - dir := t.TempDir() - logs := NewLogService(dir) + logs := NewLogService(newTestStorageBackend(t)) if err := logs.Add("新增账号", map[string]any{"module": "accounts", "operation_type": "新增", "added": 1}); err != nil { t.Fatalf("Add(account event) error = %v", err) @@ -67,10 +61,32 @@ func TestLogServiceSearchFiltersUnifiedLogs(t *testing.T) { }); err != nil { t.Fatalf("Add(audit event) error = %v", err) } + if err := logs.Add("GET /api/profile", map[string]any{ + "username": "admin", + "module": "profile", + "method": "GET", + "path": "/api/profile", + "status": 200, + "operation_type": "查询", + "log_level": "info", + }); err != nil { + t.Fatalf("Add(noisy get audit event) error = %v", err) + } + if err := logs.Add("POST /api/settings", map[string]any{ + "username": "admin", + "module": "settings", + "method": "POST", + "path": "/api/settings", + "status": 200, + "operation_type": "提交", + "log_level": "info", + }); err != nil { + t.Fatalf("Add(write audit event) error = %v", err) + } all := logs.Search(LogQuery{Limit: 10}) - if len(all) != 3 { - t.Fatalf("Search(all) length = %d, want 3: %#v", len(all), all) + if len(all) != 5 { + t.Fatalf("Search(all) length = %d, want 5: %#v", len(all), all) } for _, item := range all { if _, ok := item["type"]; ok { @@ -100,28 +116,86 @@ func TestLogServiceSearchFiltersUnifiedLogs(t *testing.T) { if _, ok := callLogs[0]["type"]; ok { t.Fatalf("Search(call) should not expose log type: %#v", callLogs) } + + meaningful := logs.Search(LogQuery{View: LogViewMeaningful, Limit: 10}) + if summaries := logSummaries(meaningful); !reflect.DeepEqual(summaries, []string{"POST /api/settings", "GET /api/settings", "文生图调用完成", "新增账号"}) { + t.Fatalf("Search(meaningful) summaries = %#v", summaries) + } + business := logs.Search(LogQuery{View: LogViewBusiness, Limit: 10}) + if summaries := logSummaries(business); !reflect.DeepEqual(summaries, []string{"文生图调用完成", "新增账号"}) { + t.Fatalf("Search(business) summaries = %#v", summaries) + } + usage := logs.UserUsageStats(1)["alice-key"] if usage == nil || usage["call_count"] != 1 || usage["success_count"] != 1 || usage["quota_used"] != 1 { t.Fatalf("UserUsageStats(new call log shape) = %#v", usage) } } -func TestLogServiceCleansOldLogs(t *testing.T) { - dir := t.TempDir() - logs := NewLogService(dir) +func logSummaries(items []map[string]any) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, util.Clean(item["summary"])) + } + return out +} + +func TestSanitizeLogValueMasksSessionCredentials(t *testing.T) { + accessToken := "access-token-secret" + sessionToken := "session-token-secret" + sanitized := SanitizeLogValue(map[string]any{ + "session_json": `{"accessToken":"` + accessToken + `","sessionToken":"` + sessionToken + `"}`, + "accessToken": accessToken, + "sessionToken": sessionToken, + }) - if err := logs.Add("旧调用", map[string]any{"status": "success"}); err != nil { - t.Fatalf("Add(old) error = %v", err) + item, ok := sanitized.(map[string]any) + if !ok { + t.Fatalf("SanitizeLogValue() = %#v", sanitized) } - if err := logs.Add("新日志", map[string]any{"status": 200}); err != nil { - t.Fatalf("Add(new) error = %v", err) + text := item["session_json"].(string) + item["accessToken"].(string) + item["sessionToken"].(string) + if strings.Contains(text, accessToken) || strings.Contains(text, sessionToken) { + t.Fatalf("sanitized log value leaked credentials: %#v", sanitized) } +} + +func TestLogServiceUserUsageStatsForUsersFiltersResults(t *testing.T) { + logs := NewLogService(newTestStorageBackend(t)) + + if err := logs.Add("Alice 调用", map[string]any{ + "key_id": "alice-key", + "endpoint": "/v1/images/generations", + "status": 200, + }); err != nil { + t.Fatalf("Add(alice) error = %v", err) + } + if err := logs.Add("Bob 调用", map[string]any{ + "key_id": "bob-key", + "endpoint": "/v1/images/generations", + "status": 200, + }); err != nil { + t.Fatalf("Add(bob) error = %v", err) + } + + usage := logs.UserUsageStatsForUsers(1, []string{"alice-key"}) + if usage["alice-key"] == nil { + t.Fatalf("missing requested user usage: %#v", usage) + } + if usage["bob-key"] != nil { + t.Fatalf("returned unrequested user usage: %#v", usage) + } +} - path := filepath.Join(dir, "logs", "events.jsonl") - data := []byte(`{"time":"2000-01-01 00:00:00","type":"event","summary":"旧调用","detail":{"status":"success"}}` + "\n" + - `{"time":"` + time.Now().Format("2006-01-02 15:04:05") + `","type":"event","summary":"新日志","detail":{"status":200}}` + "\n") - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatalf("rewrite logs: %v", err) +func TestLogServiceCleansOldLogs(t *testing.T) { + logs := NewLogService(newTestStorageBackend(t)) + + for _, item := range []map[string]any{ + {"time": "2000-01-01 00:00:00", "type": "event", "summary": "旧调用", "detail": map[string]any{"status": "success"}}, + {"time": time.Now().Format("2006-01-02 15:04:05"), "type": "event", "summary": "新日志", "detail": map[string]any{"status": 200}}, + } { + if err := logs.store.AppendLog(item); err != nil { + t.Fatalf("AppendLog() error = %v", err) + } } result, err := logs.CleanupOlderThan(1) @@ -136,3 +210,29 @@ func TestLogServiceCleansOldLogs(t *testing.T) { t.Fatalf("remaining logs = %#v", items) } } + +func TestLogServiceRetentionCleanerRunsImmediately(t *testing.T) { + logs := NewLogService(newTestStorageBackend(t)) + for _, item := range []map[string]any{ + {"time": "2000-01-01 00:00:00", "type": "event", "summary": "旧调用", "detail": map[string]any{"status": "success"}}, + {"time": time.Now().Format("2006-01-02 15:04:05"), "type": "event", "summary": "新日志", "detail": map[string]any{"status": 200}}, + } { + if err := logs.store.AppendLog(item); err != nil { + t.Fatalf("AppendLog() error = %v", err) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + logs.StartRetentionCleaner(ctx, func() int { return 1 }, time.Hour, nil) + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + items := logs.Search(LogQuery{Limit: 10}) + if len(items) == 1 && items[0]["summary"] == "新日志" { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("retention cleaner did not remove old logs, remaining = %#v", logs.Search(LogQuery{Limit: 10})) +} diff --git a/internal/service/mail_provider.go b/internal/service/mail_provider.go index 46e43372e..15530c5ee 100644 --- a/internal/service/mail_provider.go +++ b/internal/service/mail_provider.go @@ -81,6 +81,16 @@ type registerMoEmailProvider struct { entry map[string]any } +type registerInbucketMailProvider struct { + registerHTTPMailProvider + entry map[string]any +} + +type registerYYDSMailProvider struct { + registerHTTPMailProvider + entry map[string]any +} + func createRegisterMailbox(mailConfig map[string]any, username string) (map[string]any, error) { provider, err := createRegisterMailProvider(mailConfig, "", "") if err != nil { @@ -138,6 +148,10 @@ func createRegisterMailProvider(mailConfig map[string]any, providerName, provide return ®isterGPTMailProvider{registerHTTPMailProvider: base, entry: entry}, nil case "moemail": return ®isterMoEmailProvider{registerHTTPMailProvider: base, entry: entry}, nil + case "inbucket": + return ®isterInbucketMailProvider{registerHTTPMailProvider: base, entry: entry}, nil + case "yyds_mail": + return ®isterYYDSMailProvider{registerHTTPMailProvider: base, entry: entry}, nil default: return nil, fmt.Errorf("unsupported mail.provider: %s", util.Clean(entry["type"])) } @@ -638,11 +652,20 @@ func (p *registerCloudflareTempMailProvider) FetchLatestMessage(mailbox map[stri } message := latestRegisterMailMessage(messages) textContent, htmlContent := extractRegisterMailContent(message) + sender := firstNonNil(message["from"], message["sender"]) + if senderMap, ok := sender.(map[string]any); ok { + sender = firstNonNil(senderMap["address"], senderMap["email"], senderMap["name"]) + } return map[string]any{ + "provider": "cloudflare_temp_email", + "mailbox": util.Clean(mailbox["address"]), + "message_id": firstNonEmpty(util.Clean(message["id"]), util.Clean(message["_id"])), "subject": util.Clean(message["subject"]), + "sender": util.Clean(sender), "text_content": textContent, "html_content": htmlContent, - "raw": message["raw"], + "received_at": firstNonNil(message["createdAt"], message["created_at"], message["receivedAt"], message["date"], message["timestamp"]), + "raw": message, }, nil } @@ -698,30 +721,21 @@ func (p *registerTempMailLOLProvider) FetchLatestMessage(mailbox map[string]any) latest := latestRegisterMailMessage(items) textContent, htmlContent := extractRegisterMailContent(latest) return map[string]any{ + "provider": "tempmail_lol", + "mailbox": util.Clean(mailbox["address"]), + "message_id": firstNonEmpty(util.Clean(latest["id"]), util.Clean(latest["token"])), "subject": util.Clean(latest["subject"]), + "sender": firstNonEmpty(util.Clean(latest["from"]), util.Clean(latest["from_address"])), "text_content": textContent, "html_content": htmlContent, - "raw": latest["raw"], + "received_at": firstNonNil(latest["created_at"], latest["createdAt"], latest["date"], latest["received_at"], latest["timestamp"]), + "raw": latest, }, nil } func (p *registerDuckMailProvider) CreateMailbox(username string) (map[string]any, error) { apiKey := util.Clean(p.entry["api_key"]) - domains, err := registerMailRequestAny(p.client, http.MethodGet, "https://api.duckmail.sbs/domains", map[string]string{ - "Authorization": "Bearer " + apiKey, - "User-Agent": p.conf.UserAgent, - "Accept": "application/json", - }, nil, nil, http.StatusOK, http.StatusCreated) - if err != nil { - return nil, err - } domain := util.Clean(p.entry["default_domain"]) - for _, item := range duckMailItems(domains) { - if value := util.Clean(item["domain"]); value != "" { - domain = value - break - } - } if domain == "" { domain = "duckmail.sbs" } @@ -783,11 +797,20 @@ func (p *registerDuckMailProvider) FetchLatestMessage(mailbox map[string]any) (m return nil, err } textContent, htmlContent := extractRegisterMailContent(message) + sender := message["from"] + if senderMap, ok := sender.(map[string]any); ok { + sender = firstNonNil(senderMap["address"], senderMap["name"]) + } return map[string]any{ + "provider": "duckmail", + "mailbox": util.Clean(mailbox["address"]), + "message_id": messageID, "subject": util.Clean(message["subject"]), + "sender": util.Clean(sender), "text_content": textContent, "html_content": htmlContent, - "raw": message["raw"], + "received_at": firstNonNil(message["createdAt"], message["created_at"], message["receivedAt"], message["date"]), + "raw": message, }, nil } @@ -857,10 +880,15 @@ func (p *registerGPTMailProvider) FetchLatestMessage(mailbox map[string]any) (ma } textContent, htmlContent := extractRegisterMailContent(latest) return map[string]any{ + "provider": "gptmail", + "mailbox": util.Clean(mailbox["address"]), + "message_id": util.Clean(latest["id"]), "subject": util.Clean(latest["subject"]), + "sender": util.Clean(latest["from_address"]), "text_content": textContent, "html_content": htmlContent, - "raw": latest["raw"], + "received_at": firstNonNil(latest["timestamp"], latest["created_at"]), + "raw": latest, }, nil } @@ -956,6 +984,232 @@ func (p *registerMoEmailProvider) FetchLatestMessage(mailbox map[string]any) (ma }, nil } +func (p *registerInbucketMailProvider) CreateMailbox(username string) (map[string]any, error) { + apiBase := strings.TrimRight(util.Clean(p.entry["api_base"]), "/") + if apiBase == "" { + return nil, fmt.Errorf("inbucket api_base is required") + } + baseDomain, err := nextRegisterDomain(util.AsStringSlice(p.entry["domain"])) + if err != nil { + return nil, err + } + localPart := firstNonEmpty(strings.TrimSpace(username), registerRandomMailboxName()) + domain := baseDomain + randomSubdomain := true + if _, ok := p.entry["random_subdomain"]; ok { + randomSubdomain = util.ToBool(p.entry["random_subdomain"]) + } + if randomSubdomain { + domain = registerRandomSubdomainLabel() + "." + baseDomain + } + address := localPart + "@" + domain + return map[string]any{ + "provider": "inbucket", + "provider_ref": p.entry["provider_ref"], + "address": address, + "base_domain": baseDomain, + "mailbox_name": localPart, + }, nil +} + +func (p *registerInbucketMailProvider) FetchLatestMessage(mailbox map[string]any) (map[string]any, error) { + apiBase := strings.TrimRight(util.Clean(p.entry["api_base"]), "/") + if apiBase == "" { + return nil, fmt.Errorf("inbucket api_base is required") + } + mailboxName := util.Clean(mailbox["mailbox_name"]) + if mailboxName == "" { + mailboxName = registerInbucketMailboxName(util.Clean(mailbox["address"])) + } + if mailboxName == "" { + return nil, fmt.Errorf("inbucket missing mailbox_name") + } + data, err := registerMailRequestAny(p.client, http.MethodGet, apiBase+"/api/v1/mailbox/"+url.PathEscape(mailboxName), map[string]string{ + "User-Agent": p.conf.UserAgent, + "Accept": "application/json", + }, nil, nil, http.StatusOK) + if err != nil { + return nil, err + } + items := util.AsMapSlice(data) + if len(items) == 0 { + return nil, nil + } + sort.SliceStable(items, func(i, j int) bool { + left := registerMessageReceivedAt(items[i]) + right := registerMessageReceivedAt(items[j]) + if !left.IsZero() || !right.IsZero() { + if !left.Equal(right) { + return left.After(right) + } + return registerMessageID(items[i]) > registerMessageID(items[j]) + } + return false + }) + address := util.Clean(mailbox["address"]) + for _, item := range items { + messageID := util.Clean(item["id"]) + if messageID == "" { + continue + } + detail, detailErr := registerMailRequestJSON(p.client, http.MethodGet, apiBase+"/api/v1/mailbox/"+url.PathEscape(mailboxName)+"/"+url.PathEscape(messageID), map[string]string{ + "User-Agent": p.conf.UserAgent, + "Accept": "application/json", + }, nil, nil, http.StatusOK) + if detailErr != nil { + return nil, detailErr + } + header := util.StringMap(detail["header"]) + body := util.StringMap(detail["body"]) + normalized := map[string]any{ + "provider": "inbucket", + "mailbox": mailboxName, + "message_id": messageID, + "subject": firstNonEmpty(util.Clean(detail["subject"]), util.Clean(item["subject"])), + "sender": firstNonEmpty(util.Clean(detail["from"]), util.Clean(item["from"])), + "text_content": util.Clean(body["text"]), + "html_content": util.Clean(body["html"]), + "received_at": firstNonNil(detail["date"], item["date"]), + "to": firstNonNil(header["To"], header["to"]), + "raw": detail, + } + if registerMessageMatchesEmail(normalized, address) { + return normalized, nil + } + } + return nil, nil +} + +func registerInbucketMailboxName(address string) string { + localPart, _, _ := strings.Cut(strings.TrimSpace(address), "@") + return strings.TrimSpace(localPart) +} + +func (p *registerYYDSMailProvider) CreateMailbox(username string) (map[string]any, error) { + payload := map[string]any{"localPart": firstNonEmpty(strings.TrimSpace(username), registerRandomMailboxName())} + if domains := util.AsStringSlice(p.entry["domain"]); len(domains) > 0 { + domain, err := nextRegisterDomain(domains) + if err != nil { + return nil, err + } + payload["domain"] = domain + } + if subdomain := util.Clean(p.entry["subdomain"]); subdomain != "" { + payload["subdomain"] = subdomain + } + path := "/accounts" + if util.ToBool(p.entry["wildcard"]) { + path = "/accounts/wildcard" + } + data, err := p.request(http.MethodPost, path, "", nil, payload, http.StatusOK, http.StatusCreated, http.StatusNoContent) + if err != nil { + return nil, err + } + body := util.StringMap(data) + address := firstNonEmpty(util.Clean(body["address"]), util.Clean(body["email"])) + token := firstNonEmpty(util.Clean(body["token"]), util.Clean(body["temp_token"]), util.Clean(body["tempToken"]), util.Clean(body["access_token"])) + if address == "" || token == "" { + return nil, fmt.Errorf("YYDSMail missing address or token") + } + return map[string]any{ + "provider": "yyds_mail", + "provider_ref": p.entry["provider_ref"], + "address": address, + "token": token, + "account_id": util.Clean(body["id"]), + }, nil +} + +func (p *registerYYDSMailProvider) FetchLatestMessage(mailbox map[string]any) (map[string]any, error) { + token := util.Clean(mailbox["token"]) + if token == "" { + return nil, fmt.Errorf("YYDSMail missing token") + } + data, err := p.request(http.MethodGet, "/messages", token, map[string]string{"address": util.Clean(mailbox["address"])}, nil, http.StatusOK, http.StatusCreated, http.StatusNoContent) + if err != nil { + return nil, err + } + items := yydsMailItems(data) + if len(items) == 0 { + return nil, nil + } + latest := latestRegisterMailMessage(items) + messageID := firstNonEmpty(util.Clean(latest["id"]), util.Clean(latest["message_id"])) + message := latest + raw := any(latest) + if messageID != "" { + detail, detailErr := p.request(http.MethodGet, "/messages/"+url.PathEscape(messageID), token, map[string]string{"address": util.Clean(mailbox["address"])}, nil, http.StatusOK, http.StatusCreated, http.StatusNoContent) + if detailErr != nil { + return nil, detailErr + } + raw = detail + if detailMap := util.StringMap(detail); len(detailMap) > 0 { + message = detailMap + } + } + textContent, htmlContent := extractRegisterMailContent(message) + sender := firstNonNil(message["from"], message["sender"]) + if senderMap, ok := sender.(map[string]any); ok { + sender = firstNonNil(senderMap["address"], senderMap["email"], senderMap["name"]) + } + return map[string]any{ + "provider": "yyds_mail", + "mailbox": util.Clean(mailbox["address"]), + "message_id": messageID, + "subject": util.Clean(message["subject"]), + "sender": util.Clean(sender), + "text_content": textContent, + "html_content": htmlContent, + "received_at": firstNonNil(message["createdAt"], message["created_at"], message["receivedAt"], message["date"], message["timestamp"]), + "raw": raw, + }, nil +} + +func (p *registerYYDSMailProvider) request(method, path, token string, query map[string]string, payload any, expected ...int) (any, error) { + apiBase := strings.TrimRight(firstNonEmpty(util.Clean(p.entry["api_base"]), "https://maliapi.215.im/v1"), "/") + headers := map[string]string{ + "User-Agent": p.conf.UserAgent, + "Accept": "application/json", + "Content-Type": "application/json", + } + if token != "" { + headers["Authorization"] = "Bearer " + token + } else { + headers["X-API-Key"] = util.Clean(p.entry["api_key"]) + } + data, err := registerMailRequestAny(p.client, method, apiBase+path, headers, query, payload, expected...) + if err != nil { + return nil, err + } + body, ok := data.(map[string]any) + if !ok { + return data, nil + } + if success, exists := body["success"]; exists && !util.ToBool(success) { + return nil, fmt.Errorf("YYDSMail request failed: %s", firstNonEmpty(util.Clean(body["errorCode"]), util.Clean(body["error"]), util.Clean(body["message"]), "unknown error")) + } + if nested, exists := body["data"]; exists { + switch nested.(type) { + case map[string]any, []any: + return nested, nil + } + } + return data, nil +} + +func yydsMailItems(data any) []map[string]any { + switch typed := data.(type) { + case []map[string]any: + return typed + case []any: + return util.AsMapSlice(typed) + case map[string]any: + return util.AsMapSlice(firstNonNil(typed["items"], typed["messages"], typed["data"])) + default: + return nil + } +} + func duckMailItems(data any) []map[string]any { switch typed := data.(type) { case []any: diff --git a/internal/service/mail_provider_test.go b/internal/service/mail_provider_test.go index 61eb6bb2c..7a8867246 100644 --- a/internal/service/mail_provider_test.go +++ b/internal/service/mail_provider_test.go @@ -92,3 +92,124 @@ func TestRegisterMoEmailProviderCreatesAndReadsMailbox(t *testing.T) { t.Fatalf("message metadata = %#v", message) } } + +func TestRegisterInbucketProviderCreatesAndReadsMailbox(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/mailbox/user/"): + _, _ = w.Write([]byte(`{"id":"message-2","subject":"Verify","from":"OpenAI","date":"2026-01-01T00:00:00Z","header":{"To":["user@random.example.test"]},"body":{"text":"Verification code: 333444","html":""}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/mailbox/user": + _, _ = w.Write([]byte(`[{"id":"old","subject":"Old","date":"2025-01-01T00:00:00Z"},{"id":"message-2","subject":"Verify","date":"2026-01-01T00:00:00Z"}]`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := createRegisterMailProvider(map[string]any{ + "request_timeout": 1, + "providers": []map[string]any{{ + "type": "inbucket", + "enable": true, + "api_base": server.URL, + "domain": []string{"example.test"}, + "random_subdomain": false, + }}, + }, "", "") + if err != nil { + t.Fatalf("createRegisterMailProvider() error = %v", err) + } + defer provider.Close() + + mailbox, err := provider.CreateMailbox("user") + if err != nil { + t.Fatalf("CreateMailbox() error = %v", err) + } + if mailbox["provider"] != "inbucket" || mailbox["address"] != "user@example.test" || mailbox["mailbox_name"] != "user" { + t.Fatalf("mailbox = %#v", mailbox) + } + mailbox["address"] = "user@random.example.test" + + message, err := provider.FetchLatestMessage(mailbox) + if err != nil { + t.Fatalf("FetchLatestMessage() error = %v", err) + } + if got := extractRegisterMailCode(message); got != "333444" { + t.Fatalf("extractRegisterMailCode() = %q, want 333444; message=%#v", got, message) + } + if message["message_id"] != "message-2" || message["sender"] != "OpenAI" { + t.Fatalf("message metadata = %#v", message) + } +} + +func TestRegisterYYDSMailProviderCreatesAndReadsMailbox(t *testing.T) { + var createPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/accounts/wildcard": + if r.Header.Get("X-API-Key") != "secret-key" { + t.Errorf("X-API-Key = %q", r.Header.Get("X-API-Key")) + } + if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil { + t.Errorf("decode create payload: %v", err) + } + _, _ = w.Write([]byte(`{"success":true,"data":{"address":"user@example.test","token":"mail-token","id":"account-1"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/messages/message-2": + if r.Header.Get("Authorization") != "Bearer mail-token" { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte(`{"success":true,"data":{"id":"message-2","subject":"Verify","text":"Verification code: 555666","timestamp":200,"from":{"email":"noreply@example.test"}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/messages": + if r.URL.Query().Get("address") != "user@example.test" { + t.Errorf("address query = %q", r.URL.Query().Get("address")) + } + _, _ = w.Write([]byte(`{"success":true,"data":{"items":[{"id":"old","subject":"Old","timestamp":100},{"id":"message-2","subject":"Verify","timestamp":200}]}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := createRegisterMailProvider(map[string]any{ + "request_timeout": 1, + "providers": []map[string]any{{ + "type": "yyds_mail", + "enable": true, + "api_base": server.URL, + "api_key": "secret-key", + "domain": []string{"example.test"}, + "subdomain": "sub", + "wildcard": true, + }}, + }, "", "") + if err != nil { + t.Fatalf("createRegisterMailProvider() error = %v", err) + } + defer provider.Close() + + mailbox, err := provider.CreateMailbox("user") + if err != nil { + t.Fatalf("CreateMailbox() error = %v", err) + } + if mailbox["provider"] != "yyds_mail" || mailbox["address"] != "user@example.test" || mailbox["token"] != "mail-token" { + t.Fatalf("mailbox = %#v", mailbox) + } + if createPayload["localPart"] != "user" || createPayload["domain"] != "example.test" || createPayload["subdomain"] != "sub" { + t.Fatalf("create payload = %#v", createPayload) + } + + message, err := provider.FetchLatestMessage(mailbox) + if err != nil { + t.Fatalf("FetchLatestMessage() error = %v", err) + } + if got := extractRegisterMailCode(message); got != "555666" { + t.Fatalf("extractRegisterMailCode() = %q, want 555666; message=%#v", got, message) + } + if message["message_id"] != "message-2" || message["sender"] != "noreply@example.test" { + t.Fatalf("message metadata = %#v", message) + } +} diff --git a/internal/service/password_accounts.go b/internal/service/password_accounts.go index 6f4d9d43d..83a2ef88e 100644 --- a/internal/service/password_accounts.go +++ b/internal/service/password_accounts.go @@ -120,8 +120,8 @@ func (s *AuthService) RegisterPasswordUser(username, password, name string) (*Id } s.mu.Lock() - defer s.mu.Unlock() if _, ok := passwordAccountByUsernameLocked(s.accounts, username); ok { + s.mu.Unlock() return nil, "", authError("username already exists") } now := util.NowISO() @@ -139,12 +139,17 @@ func (s *AuthService) RegisterPasswordUser(username, password, name string) (*Id s.accounts = append(s.accounts, account) item, raw := s.issuePasswordSessionLocked(account, now) if err := s.savePasswordAccountsLocked(); err != nil { + s.mu.Unlock() return nil, "", err } if err := s.saveLocked(); err != nil { + s.mu.Unlock() return nil, "", err } - return identityForAuthItem(item), raw, nil + identity := identityForAuthItem(item) + s.mu.Unlock() + s.notifyUserCreated(account.ID) + return identity, raw, nil } func (s *AuthService) CreatePasswordUser(username, password, name, roleID string, enabled bool) (map[string]any, error) { @@ -166,12 +171,13 @@ func (s *AuthService) CreatePasswordUser(username, password, name, roleID string } s.mu.Lock() - defer s.mu.Unlock() if _, ok := passwordAccountByUsernameLocked(s.accounts, username); ok { + s.mu.Unlock() return nil, authError("username already exists") } role, ok := managedRoleByIDLocked(s.roles, roleID) if !ok { + s.mu.Unlock() return nil, authError("role not found") } now := util.NowISO() @@ -188,9 +194,13 @@ func (s *AuthService) CreatePasswordUser(username, password, name, roleID string } s.accounts = append(s.accounts, account) if err := s.savePasswordAccountsLocked(); err != nil { + s.mu.Unlock() return nil, err } - return managedAuthUserByIDLocked(s.items, s.roles, s.accounts, account.ID), nil + item := managedAuthUserByIDLocked(s.items, s.roles, s.accounts, account.ID) + s.mu.Unlock() + s.notifyUserCreated(account.ID) + return item, nil } func (s *AuthService) LoginPassword(username, password string) (*Identity, string, error) { diff --git a/internal/service/permissions.go b/internal/service/permissions.go index 4f8c55f6f..c1e1a7536 100644 --- a/internal/service/permissions.go +++ b/internal/service/permissions.go @@ -53,6 +53,8 @@ var apiPermissionCatalog = []APIPermission{ apiPermission("GET", "/api/images", "查看图片库", "图片库", false), apiPermission("PATCH", "/api/images/visibility", "发布/收回图片", "图片库", false), apiPermission("DELETE", "/api/images", "删除图片", "图片库", false), + apiPermission("GET", "/api/images/storage-governance", "查看图片存储治理", "图片库", false), + apiPermission("POST", "/api/images/storage-governance", "清理图片存储", "图片库", false), apiPermission("GET", "/api/auth/users", "查看个人 API 令牌", "用户令牌", true), apiPermission("POST", "/api/auth/users", "创建/更新个人 API 令牌", "用户令牌", true), apiPermission("DELETE", "/api/auth/users", "删除个人 API 令牌", "用户令牌", true), @@ -60,6 +62,7 @@ var apiPermissionCatalog = []APIPermission{ apiPermission("GET", "/api/accounts", "查看号池", "号池管理", false), apiPermission("GET", "/api/accounts/tokens", "导出号池 Token", "号池管理", false), apiPermission("POST", "/api/accounts", "导入号池 Token", "号池管理", false), + apiPermission("POST", "/api/accounts/session", "通过 Session 导入号池账号", "号池管理", false), apiPermission("POST", "/api/accounts/refresh", "刷新号池", "号池管理", false), apiPermission("POST", "/api/accounts/update", "编辑号池账号", "号池管理", false), apiPermission("DELETE", "/api/accounts", "删除号池账号", "号池管理", false), diff --git a/internal/service/permissions_test.go b/internal/service/permissions_test.go index 36f934e44..6a340a4da 100644 --- a/internal/service/permissions_test.go +++ b/internal/service/permissions_test.go @@ -34,6 +34,7 @@ func TestAccountPoolPermissionsAreExplicit(t *testing.T) { operators := PermissionSet{APIPermissions: NormalizeAPIPermissions([]string{ APIPermissionKey("GET", "/api/accounts/tokens"), APIPermissionKey("POST", "/api/accounts"), + APIPermissionKey("POST", "/api/accounts/session"), APIPermissionKey("POST", "/api/accounts/refresh"), APIPermissionKey("POST", "/api/accounts/update"), APIPermissionKey("DELETE", "/api/accounts"), @@ -44,6 +45,7 @@ func TestAccountPoolPermissionsAreExplicit(t *testing.T) { }{ {"GET", "/api/accounts/tokens"}, {"POST", "/api/accounts"}, + {"POST", "/api/accounts/session"}, {"POST", "/api/accounts/refresh"}, {"POST", "/api/accounts/update"}, {"DELETE", "/api/accounts"}, diff --git a/internal/service/prompt_favorite.go b/internal/service/prompt_favorite.go index ad62a2ad0..4f77a5c1d 100644 --- a/internal/service/prompt_favorite.go +++ b/internal/service/prompt_favorite.go @@ -2,7 +2,6 @@ package service import ( "fmt" - "path/filepath" "sort" "sync" @@ -13,13 +12,12 @@ import ( const promptFavoritesDocumentDir = "prompt_favorites" type PromptFavoriteService struct { - mu sync.Mutex - dataDir string - store storage.JSONDocumentBackend + mu sync.Mutex + store storage.JSONDocumentBackend } -func NewPromptFavoriteService(dataDir string, backend ...storage.Backend) *PromptFavoriteService { - return &PromptFavoriteService{dataDir: dataDir, store: firstJSONDocumentStore(backend)} +func NewPromptFavoriteService(backend ...storage.Backend) *PromptFavoriteService { + return &PromptFavoriteService{store: firstJSONDocumentStore(backend)} } func (s *PromptFavoriteService) List(ownerID string) []map[string]any { @@ -99,8 +97,7 @@ func (s *PromptFavoriteService) Delete(ownerID, id string) bool { func (s *PromptFavoriteService) loadLocked(ownerID string) []map[string]any { name := promptFavoriteDocumentName(ownerID) - path := filepath.Join(s.dataDir, promptFavoritesDocumentDir, util.SHA256Hex(ownerID)+".json") - raw := loadStoredJSON(s.store, name, path) + raw := loadStoredJSON(s.store, name) items := make([]map[string]any, 0) for _, item := range util.AsMapSlice(util.StringMap(raw)["items"]) { if normalized := normalizeStoredPromptFavorite(item); normalized != nil { @@ -113,8 +110,7 @@ func (s *PromptFavoriteService) loadLocked(ownerID string) []map[string]any { func (s *PromptFavoriteService) saveLocked(ownerID string, items []map[string]any) error { name := promptFavoriteDocumentName(ownerID) - path := filepath.Join(s.dataDir, promptFavoritesDocumentDir, util.SHA256Hex(ownerID)+".json") - return saveStoredJSON(s.store, name, path, map[string]any{"items": items}) + return saveStoredJSON(s.store, name, map[string]any{"items": items}) } func promptFavoriteDocumentName(ownerID string) string { diff --git a/internal/service/prompt_favorite_test.go b/internal/service/prompt_favorite_test.go index d644743a0..d77037de5 100644 --- a/internal/service/prompt_favorite_test.go +++ b/internal/service/prompt_favorite_test.go @@ -1,16 +1,10 @@ package service -import ( - "path/filepath" - "testing" - - "chatgpt2api/internal/storage" -) +import "testing" func TestPromptFavoriteServiceUpsertListAndDelete(t *testing.T) { - root := t.TempDir() - backend := storage.NewJSONBackend(filepath.Join(root, "accounts.json"), filepath.Join(root, "auth_keys.json")) - service := NewPromptFavoriteService(root, backend) + backend := newTestStorageBackend(t) + service := NewPromptFavoriteService(backend) item, err := service.Upsert("user_1", map[string]any{ "prompt_id": "prompt-a", @@ -93,8 +87,7 @@ func TestPromptFavoriteServiceUpsertListAndDelete(t *testing.T) { } func TestPromptFavoriteServiceRejectsInvalidInput(t *testing.T) { - root := t.TempDir() - service := NewPromptFavoriteService(root, storage.NewJSONBackend(filepath.Join(root, "accounts.json"), filepath.Join(root, "auth_keys.json"))) + service := NewPromptFavoriteService(newTestStorageBackend(t)) cases := []map[string]any{ {"source": "banana-prompt-quicker", "title": "Title", "preview": "https://example.test/a.png", "prompt": "draw", "author": "Alice"}, diff --git a/internal/service/register.go b/internal/service/register.go index 25aa28aed..78112308f 100644 --- a/internal/service/register.go +++ b/internal/service/register.go @@ -14,7 +14,6 @@ import ( "net/http" "net/http/cookiejar" "net/url" - "path/filepath" "strconv" "strings" "sync" @@ -51,7 +50,6 @@ var ( type RegisterService struct { mu sync.Mutex - path string store storage.JSONDocumentBackend docName string accounts *AccountService @@ -84,9 +82,8 @@ type registerSentinelTokenGenerator struct { sid string } -func NewRegisterService(dataDir string, accounts *AccountService, backend ...storage.Backend) *RegisterService { +func NewRegisterService(accounts *AccountService, backend ...storage.Backend) *RegisterService { s := &RegisterService{ - path: filepath.Join(dataDir, "register.json"), store: firstJSONDocumentStore(backend), docName: "register.json", accounts: accounts, @@ -492,21 +489,47 @@ func (w *registerWorker) loginAndExchangeTokens(ctx context.Context, email, pass w.step("开始独立登录换 token") codeVerifier, codeChallenge := generateRegisterPKCE() values := registerAuthorizeParams(email, w.deviceID, registerRandomToken(), registerRandomToken(), codeChallenge) - status, _, err := w.request(ctx, http.MethodGet, registerAuthBase+"/api/accounts/authorize?"+values.Encode(), nil, w.navigateHeaders(registerPlatformBase+"/"), true) + authorizeLogin := func() error { + status, _, err := w.request(ctx, http.MethodGet, registerAuthBase+"/api/accounts/authorize?"+values.Encode(), nil, w.navigateHeaders(registerPlatformBase+"/"), true) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("platform_login_authorize_http_%d", status) + } + return nil + } + if err := authorizeLogin(); err != nil { + return nil, err + } + w.step("登录 authorize 完成") + + status, payload, err := w.submitLoginEmail(ctx, email) if err != nil { return nil, err } + if status == http.StatusConflict { + w.step("邮箱提交 invalid_state,重新 authorize 后重试") + if err := authorizeLogin(); err != nil { + return nil, err + } + status, payload, err = w.submitLoginEmail(ctx, email) + if err != nil { + return nil, err + } + } if status != http.StatusOK { - return nil, fmt.Errorf("platform_login_authorize_http_%d", status) + return nil, fmt.Errorf("email_submit_http_%d%s", status, registerResponseDetail(payload)) } - w.step("登录 authorize 完成") + w.step("邮箱提交完成") + headers := w.jsonHeaders(registerAuthBase + "/log-in/password") token, err := w.buildSentinelToken(ctx, "password_verify") if err != nil { return nil, err } headers["openai-sentinel-token"] = token - status, payload, err := w.request(ctx, http.MethodPost, registerAuthBase+"/api/accounts/password/verify", map[string]any{ + status, payload, err = w.request(ctx, http.MethodPost, registerAuthBase+"/api/accounts/password/verify", map[string]any{ "password": password, }, headers, false) if err != nil { @@ -573,6 +596,22 @@ func (w *registerWorker) loginAndExchangeTokens(ctx context.Context, email, pass }, nil } +func (w *registerWorker) submitLoginEmail(ctx context.Context, email string) (int, map[string]any, error) { + w.step("开始提交邮箱") + headers := w.jsonHeaders(registerAuthBase + "/log-in?usernameKind=email") + token, err := w.buildSentinelToken(ctx, "authorize_continue") + if err != nil { + return 0, nil, err + } + headers["openai-sentinel-token"] = token + return w.request(ctx, http.MethodPost, registerAuthBase+"/api/accounts/authorize/continue", map[string]any{ + "username": map[string]any{ + "kind": "email", + "value": email, + }, + }, headers, false) +} + func (w *registerWorker) followConsentForCode(ctx context.Context, continueURL string) (string, error) { current := continueURL if strings.HasPrefix(current, "/") { @@ -1059,7 +1098,7 @@ func normalizeRegisterMailConfig(raw map[string]any) map[string]any { } func (s *RegisterService) load() map[string]any { - raw, ok := loadStoredJSON(s.store, s.docName, s.path).(map[string]any) + raw, ok := loadStoredJSON(s.store, s.docName).(map[string]any) if !ok { return normalizeRegisterConfig(nil) } @@ -1067,7 +1106,7 @@ func (s *RegisterService) load() map[string]any { } func (s *RegisterService) saveLocked() { - _ = saveStoredJSON(s.store, s.docName, s.path, s.config) + _ = saveStoredJSON(s.store, s.docName, s.config) } func (s *RegisterService) snapshotLocked() map[string]any { diff --git a/internal/service/register_flow_test.go b/internal/service/register_flow_test.go index 51ac3d767..e2f2d7c65 100644 --- a/internal/service/register_flow_test.go +++ b/internal/service/register_flow_test.go @@ -148,6 +148,58 @@ func TestSelectWorkspaceForConsentCodeUsesCookieFallback(t *testing.T) { } } +func TestLoginAndExchangeTokensSubmitsEmailBeforePassword(t *testing.T) { + var sequence []string + worker := ®isterWorker{ + service: &RegisterService{}, + deviceID: "device-1", + client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/api/accounts/authorize": + sequence = append(sequence, "authorize") + return registerJSONResponse(req, http.StatusOK, `{}`), nil + case "/backend-api/sentinel/req": + return registerJSONResponse(req, http.StatusOK, `{"token":"challenge-token","proofofwork":{"required":false}}`), nil + case "/api/accounts/authorize/continue": + sequence = append(sequence, "email") + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode authorize/continue body: %v", err) + } + username := body["username"].(map[string]any) + if username["kind"] != "email" || username["value"] != "user@example.test" { + t.Fatalf("authorize/continue body = %#v", body) + } + return registerJSONResponse(req, http.StatusOK, `{}`), nil + case "/api/accounts/password/verify": + sequence = append(sequence, "password") + return registerJSONResponse(req, http.StatusOK, `{"continue_url":"`+registerPlatformOAuthRedirectURI+`?code=callback-code&state=state"}`), nil + case "/auth/callback": + sequence = append(sequence, "callback") + return registerJSONResponse(req, http.StatusOK, `{}`), nil + case "/oauth/token": + sequence = append(sequence, "token") + return registerJSONResponse(req, http.StatusOK, `{"access_token":"access","refresh_token":"refresh","id_token":"id"}`), nil + default: + t.Fatalf("unexpected request path: %s", req.URL.Path) + return nil, nil + } + })}, + } + + tokens, err := worker.loginAndExchangeTokens(context.Background(), "user@example.test", "Password123!", map[string]any{"address": "user@example.test"}) + if err != nil { + t.Fatalf("loginAndExchangeTokens() error = %v", err) + } + if tokens["access_token"] != "access" || tokens["refresh_token"] != "refresh" || tokens["id_token"] != "id" { + t.Fatalf("tokens = %#v", tokens) + } + want := []string{"authorize", "email", "password", "callback", "token"} + if strings.Join(sequence, ",") != strings.Join(want, ",") { + t.Fatalf("request sequence = %#v, want %#v", sequence, want) + } +} + func TestRegisterHTTPClientUsesSOCKSTransport(t *testing.T) { client, err := registerHTTPClient("socks5h://127.0.0.1:1", time.Second, "device-1") if err != nil { diff --git a/internal/service/session_refresher.go b/internal/service/session_refresher.go new file mode 100644 index 000000000..11df99140 --- /dev/null +++ b/internal/service/session_refresher.go @@ -0,0 +1,221 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// SessionRefresher refreshes tokens through /api/auth/session with a uTLS client. +type SessionRefresher struct { + mu sync.Mutex + inFlight map[string]*refreshCall // key: access_token, deduplicates refreshes + semaphore chan struct{} // concurrency control (max 5 concurrent) + httpDo func(req *http.Request) (*http.Response, error) +} + +type refreshCall struct { + done chan struct{} + result refreshResult +} + +type SessionRefreshData struct { + AccessToken string + SessionToken string + Expires string + User SessionRefreshUser +} + +type SessionRefreshUser struct { + ID string + Name string + Email string +} + +type refreshResult struct { + accessToken string + sessionToken string + sessionExpires string + user SessionRefreshUser + err error +} + +const ( + maxConcurrentRefreshes = 5 + refreshTimeout = 15 * time.Second + sessionEndpoint = "https://chatgpt.com/api/auth/session" +) + +func NewSessionRefresher(httpDo func(req *http.Request) (*http.Response, error)) *SessionRefresher { + return &SessionRefresher{ + inFlight: make(map[string]*refreshCall), + semaphore: make(chan struct{}, maxConcurrentRefreshes), + httpDo: httpDo, + } +} + +// RefreshToken refreshes access_token with session_token. +// If the same token is already refreshing, it waits for the in-flight result. +func (r *SessionRefresher) RefreshToken(ctx context.Context, accessToken, sessionToken string) (newAccessToken, newSessionToken, newExpires string, err error) { + result, err := r.RefreshSession(ctx, accessToken, sessionToken) + return result.AccessToken, result.SessionToken, result.Expires, err +} + +func (r *SessionRefresher) RefreshSession(ctx context.Context, accessToken, sessionToken string) (SessionRefreshData, error) { + if sessionToken == "" { + return SessionRefreshData{}, fmt.Errorf("session_token is empty") + } + + // Deduplicate in-flight refreshes for the same access token. + r.mu.Lock() + if call, ok := r.inFlight[accessToken]; ok { + r.mu.Unlock() + select { + case <-call.done: + return call.result.sessionData(), call.result.err + case <-ctx.Done(): + return SessionRefreshData{}, ctx.Err() + } + } + call := &refreshCall{done: make(chan struct{})} + r.inFlight[accessToken] = call + r.mu.Unlock() + + finish := func(result refreshResult) (SessionRefreshData, error) { + call.result = result + close(call.done) + r.mu.Lock() + delete(r.inFlight, accessToken) + r.mu.Unlock() + return result.sessionData(), result.err + } + + // Acquire the refresh concurrency slot. + select { + case r.semaphore <- struct{}{}: + defer func() { <-r.semaphore }() + case <-ctx.Done(): + return finish(refreshResult{err: ctx.Err()}) + } + + // Execute the refresh request. + return finish(r.doRefresh(ctx, sessionToken)) +} + +func (r refreshResult) sessionData() SessionRefreshData { + return SessionRefreshData{ + AccessToken: r.accessToken, + SessionToken: r.sessionToken, + Expires: r.sessionExpires, + User: r.user, + } +} + +func (r *SessionRefresher) doRefresh(ctx context.Context, sessionToken string) refreshResult { + ctx, cancel := context.WithTimeout(ctx, refreshTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sessionEndpoint, nil) + if err != nil { + return refreshResult{err: fmt.Errorf("create request: %w", err)} + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json") + req.AddCookie(&http.Cookie{ + Name: "__Secure-next-auth.session-token", + Value: sessionToken, + Domain: ".chatgpt.com", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + resp, err := r.httpDo(req) + if err != nil { + return refreshResult{err: fmt.Errorf("http request: %w", err)} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return refreshResult{err: fmt.Errorf("read body: %w", err)} + } + + if resp.StatusCode != http.StatusOK { + preview := string(body) + if len(preview) > 300 { + preview = preview[:300] + } + return refreshResult{err: fmt.Errorf("session endpoint returned %d: %s", resp.StatusCode, preview)} + } + + var session struct { + AccessToken string `json:"accessToken"` + Expires string `json:"expires"` + SessionToken string `json:"sessionToken"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` + } + if err := json.Unmarshal(body, &session); err != nil { + return refreshResult{err: fmt.Errorf("parse session response: %w", err)} + } + + if session.AccessToken == "" { + return refreshResult{err: fmt.Errorf("session response missing accessToken")} + } + + // Keep the previous sessionToken when the response omits a replacement. + newSessionToken := session.SessionToken + if newSessionToken == "" { + newSessionToken = sessionToken + } + + return refreshResult{ + accessToken: session.AccessToken, + sessionToken: newSessionToken, + sessionExpires: session.Expires, + user: SessionRefreshUser{ + ID: session.User.ID, + Name: session.User.Name, + Email: session.User.Email, + }, + } +} + +// IsRefreshing reports whether the given token is being refreshed. +func (r *SessionRefresher) IsRefreshing(accessToken string) bool { + r.mu.Lock() + defer r.mu.Unlock() + _, ok := r.inFlight[accessToken] + return ok +} + +// TryRefreshAsync triggers a fire-and-forget refresh for live request paths. +// It returns true when a refresh has been submitted or is already in flight. +func (r *SessionRefresher) TryRefreshAsync(accessToken, sessionToken string) bool { + if sessionToken == "" { + return false + } + r.mu.Lock() + if _, ok := r.inFlight[accessToken]; ok { + r.mu.Unlock() + return true // Already refreshing. + } + r.mu.Unlock() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout) + defer cancel() + r.RefreshToken(ctx, accessToken, sessionToken) + }() + return true +} diff --git a/internal/service/session_refresher_test.go b/internal/service/session_refresher_test.go new file mode 100644 index 000000000..cebb9edba --- /dev/null +++ b/internal/service/session_refresher_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestSessionRefresherRejectsEmptySessionToken(t *testing.T) { + refresher := NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + t.Fatalf("httpDo should not be called for empty session token") + return nil, nil + }) + + _, _, _, err := refresher.RefreshToken(context.Background(), "access-token", "") + if err == nil || !strings.Contains(err.Error(), "session_token is empty") { + t.Fatalf("expected empty session token error, got %v", err) + } +} + +func TestSessionRefresherReturnsValidatedUser(t *testing.T) { + refresher := NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"new-access","sessionToken":"new-session","expires":"2026-05-12T00:00:00Z","user":{"id":"user-123","email":"user@example.com","name":"User Name"}}`)), + }, nil + }) + + result, err := refresher.RefreshSession(context.Background(), "old-access", "old-session") + if err != nil { + t.Fatalf("RefreshSession() error = %v", err) + } + if result.AccessToken != "new-access" || result.SessionToken != "new-session" || result.Expires != "2026-05-12T00:00:00Z" { + t.Fatalf("RefreshSession() tokens = %#v", result) + } + if result.User.ID != "user-123" || result.User.Email != "user@example.com" || result.User.Name != "User Name" { + t.Fatalf("RefreshSession() user = %#v", result.User) + } +} + +func TestSessionRefresherDeduplicatesConcurrentRefreshes(t *testing.T) { + var calls int32 + release := make(chan struct{}) + refresher := NewSessionRefresher(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + <-release + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"new-access","sessionToken":"new-session","expires":"2026-05-12T00:00:00Z"}`)), + }, nil + }) + + const waiters = 5 + var wg sync.WaitGroup + results := make(chan refreshResult, waiters) + for i := 0; i < waiters; i++ { + wg.Add(1) + go func() { + defer wg.Done() + accessToken, sessionToken, expires, err := refresher.RefreshToken(context.Background(), "old-access", "old-session") + results <- refreshResult{ + accessToken: accessToken, + sessionToken: sessionToken, + sessionExpires: expires, + err: err, + } + }() + } + + waitForCondition(t, func() bool { + return atomic.LoadInt32(&calls) == 1 && refresher.IsRefreshing("old-access") + }) + close(release) + wg.Wait() + close(results) + + if calls := atomic.LoadInt32(&calls); calls != 1 { + t.Fatalf("expected one upstream refresh, got %d", calls) + } + if refresher.IsRefreshing("old-access") { + t.Fatalf("refresh should be cleared after completion") + } + for result := range results { + if result.err != nil { + t.Fatalf("refresh returned error: %v", result.err) + } + if result.accessToken != "new-access" || result.sessionToken != "new-session" || result.sessionExpires != "2026-05-12T00:00:00Z" { + t.Fatalf("unexpected refresh result: %#v", result) + } + } +} + +func waitForCondition(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("condition was not met before deadline") +} diff --git a/internal/service/storage_helpers.go b/internal/service/storage_helpers.go index 19b1d0a5e..9ab59a9c9 100644 --- a/internal/service/storage_helpers.go +++ b/internal/service/storage_helpers.go @@ -1,10 +1,7 @@ package service import ( - "encoding/json" - "os" - "path/filepath" - "strings" + "fmt" "chatgpt2api/internal/storage" ) @@ -23,59 +20,20 @@ func firstJSONDocumentStore(backends []storage.Backend) storage.JSONDocumentBack return jsonDocumentStoreFromBackend(backends[0]) } -func logStoreFromBackend(backend storage.Backend) storage.LogBackend { - if store, ok := backend.(storage.LogBackend); ok { - return store - } - return nil -} - -func firstLogStore(backends []storage.Backend) storage.LogBackend { - if len(backends) == 0 { +func loadStoredJSON(store storage.JSONDocumentBackend, name string) any { + if store == nil { return nil } - return logStoreFromBackend(backends[0]) -} - -func loadStoredJSON(store storage.JSONDocumentBackend, name, filePath string) any { - if store != nil { - value, err := store.LoadJSONDocument(name) - if err == nil { - return value - } - } - value, _ := readJSONValueFile(filePath) - return value -} - -func saveStoredJSON(store storage.JSONDocumentBackend, name, filePath string, value any) error { - if store != nil { - return store.SaveJSONDocument(name, value) - } - return writeJSONValueFile(filePath, value) -} - -func readJSONValueFile(filePath string) (any, error) { - data, err := os.ReadFile(filePath) + value, err := store.LoadJSONDocument(name) if err != nil { - return nil, err - } - var raw any - dec := json.NewDecoder(strings.NewReader(string(data))) - dec.UseNumber() - if err := dec.Decode(&raw); err != nil { - return nil, err + return nil } - return raw, nil + return value } -func writeJSONValueFile(filePath string, value any) error { - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err +func saveStoredJSON(store storage.JSONDocumentBackend, name string, value any) error { + if store == nil { + return fmt.Errorf("storage document backend is required") } - return os.WriteFile(filePath, append(data, '\n'), 0o644) + return store.SaveJSONDocument(name, value) } diff --git a/internal/service/sub2api.go b/internal/service/sub2api.go index 4c0acb247..dd4104eb4 100644 --- a/internal/service/sub2api.go +++ b/internal/service/sub2api.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "path/filepath" "strings" "sync" "time" @@ -17,7 +16,6 @@ import ( type Sub2APIConfig struct { mu sync.Mutex - path string store storage.JSONDocumentBackend servers []map[string]any docName string @@ -35,8 +33,8 @@ type cachedJWT struct { expiresAt time.Time } -func NewSub2APIConfig(dataDir string, backend ...storage.Backend) *Sub2APIConfig { - c := &Sub2APIConfig{path: filepath.Join(dataDir, "sub2api_config.json"), store: firstJSONDocumentStore(backend), docName: "sub2api_config.json"} +func NewSub2APIConfig(backend ...storage.Backend) *Sub2APIConfig { + c := &Sub2APIConfig{store: firstJSONDocumentStore(backend), docName: "sub2api_config.json"} c.servers = c.load() return c } @@ -134,7 +132,7 @@ func (c *Sub2APIConfig) GetImportJob(id string) map[string]any { } func (c *Sub2APIConfig) load() []map[string]any { - raw := util.AsMapSlice(loadStoredJSON(c.store, c.docName, c.path)) + raw := util.AsMapSlice(loadStoredJSON(c.store, c.docName)) out := make([]map[string]any, 0, len(raw)) for _, item := range raw { out = append(out, normalizeSub2Server(item)) @@ -143,7 +141,7 @@ func (c *Sub2APIConfig) load() []map[string]any { } func (c *Sub2APIConfig) saveLocked() error { - return saveStoredJSON(c.store, c.docName, c.path, c.servers) + return saveStoredJSON(c.store, c.docName, c.servers) } func (s *Sub2APIService) ListRemoteGroups(ctx context.Context, server map[string]any) ([]map[string]any, error) { diff --git a/internal/service/sub2api_test.go b/internal/service/sub2api_test.go index bbd16c632..f0899b005 100644 --- a/internal/service/sub2api_test.go +++ b/internal/service/sub2api_test.go @@ -20,7 +20,7 @@ func TestSub2APIListRemoteGroupsUsesActiveOpenAIGroupsEndpoint(t *testing.T) { })) defer server.Close() - service := NewSub2APIService(NewSub2APIConfig(t.TempDir()), nil) + service := NewSub2APIService(NewSub2APIConfig(newTestStorageBackend(t)), nil) groups, err := service.ListRemoteGroups(context.Background(), map[string]any{ "base_url": server.URL, "api_key": "test-key", @@ -46,7 +46,7 @@ func TestSub2APIListRemoteGroupsReturnsEmptyArrayForNullItems(t *testing.T) { })) defer server.Close() - service := NewSub2APIService(NewSub2APIConfig(t.TempDir()), nil) + service := NewSub2APIService(NewSub2APIConfig(newTestStorageBackend(t)), nil) groups, err := service.ListRemoteGroups(context.Background(), map[string]any{ "base_url": server.URL, "api_key": "test-key", diff --git a/internal/service/test_helpers_test.go b/internal/service/test_helpers_test.go new file mode 100644 index 000000000..f7d32a5cc --- /dev/null +++ b/internal/service/test_helpers_test.go @@ -0,0 +1,21 @@ +package service + +import ( + "path/filepath" + "testing" + + "chatgpt2api/internal/storage" +) + +func newTestStorageBackend(t *testing.T) storage.Backend { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "chatgpt2api.db") + backend, err := storage.NewDatabaseBackend("sqlite:///" + filepath.ToSlash(dbPath)) + if err != nil { + t.Fatalf("NewDatabaseBackend() error = %v", err) + } + t.Cleanup(func() { + _ = backend.Close() + }) + return backend +} diff --git a/internal/service/update_test.go b/internal/service/update_test.go index 1257ae02a..2defec288 100644 --- a/internal/service/update_test.go +++ b/internal/service/update_test.go @@ -80,12 +80,27 @@ func TestGoReleaserArchiveDoesNotShipWebDist(t *testing.T) { t.Fatalf("read .goreleaser.yaml: %v", err) } config := string(data) + if !strings.Contains(config, "main: ./internal") { + t.Fatal(".goreleaser.yaml must build the current internal main package") + } if strings.Contains(config, "web_dist") { t.Fatal(".goreleaser.yaml must not ship runtime web_dist assets") } if !strings.Contains(config, "-tags=embed") { t.Fatal(".goreleaser.yaml must build the binary with embedded frontend assets") } + if !strings.Contains(config, "- deploy/docker-compose.yml") { + t.Fatal(".goreleaser.yaml archive must ship deploy/docker-compose.yml") + } + if strings.Contains(config, "- docker-compose.yml") { + t.Fatal(".goreleaser.yaml archive must not reference root docker-compose.yml") + } + if !strings.Contains(config, "dockerfile: deploy/Dockerfile.release") { + t.Fatal(".goreleaser.yaml Docker images must use deploy/Dockerfile.release") + } + if strings.Contains(config, "Dockerfile.goreleaser") { + t.Fatal(".goreleaser.yaml must not reference Dockerfile.goreleaser") + } } func TestGoReleaserBuildTargetsLinuxOnly(t *testing.T) { @@ -107,6 +122,103 @@ func TestGoReleaserBuildTargetsLinuxOnly(t *testing.T) { } } +func TestReleaseWorkflowUsesSingleGoReleaserConfig(t *testing.T) { + if _, err := os.Stat(filepath.Join("..", "..", ".goreleaser.simple.yaml")); !os.IsNotExist(err) { + t.Fatal(".goreleaser.simple.yaml must not exist; releases use the main GoReleaser config") + } + data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("read release workflow: %v", err) + } + workflow := string(data) + if strings.Contains(workflow, "simple_release") { + t.Fatal("release workflow must not expose a simple_release path") + } + if strings.Contains(workflow, ".goreleaser.simple.yaml") { + t.Fatal("release workflow must not reference .goreleaser.simple.yaml") + } + if !strings.Contains(workflow, "args: release --clean --skip=validate") { + t.Fatal("release workflow must run the main GoReleaser release path") + } +} + +func TestRetiredDockerBuildFilesDoNotReturn(t *testing.T) { + for _, path := range []string{ + ".dockerignore", + "Dockerfile", + "Dockerfile.goreleaser", + "docker-compose.yml", + "docker-compose.build.yml", + "docker-compose.local.yml", + } { + if _, err := os.Stat(filepath.Join("..", "..", path)); !os.IsNotExist(err) { + t.Fatalf("%s must not exist; Docker deployment config belongs under deploy/", path) + } + } +} + +func TestServerSourceDockerBuildFilesStayUnderDeploy(t *testing.T) { + for _, path := range []string{ + filepath.Join("deploy", "Dockerfile"), + filepath.Join("deploy", "Dockerfile.dockerignore"), + filepath.Join("deploy", "docker-build-limited.sh"), + } { + if _, err := os.Stat(filepath.Join("..", "..", path)); err != nil { + t.Fatalf("%s must exist for server-side source builds: %v", path, err) + } + } + + dockerfileData, err := os.ReadFile(filepath.Join("..", "..", "deploy", "Dockerfile")) + if err != nil { + t.Fatalf("read deploy/Dockerfile: %v", err) + } + dockerfile := string(dockerfileData) + if !strings.Contains(dockerfile, "go build") || !strings.Contains(dockerfile, "./internal") { + t.Fatal("deploy/Dockerfile must build the current internal main package") + } + if strings.Contains(dockerfile, "./cmd/chatgpt2api") || strings.Contains(dockerfile, "COPY cmd ") { + t.Fatal("deploy/Dockerfile must not reference the retired cmd/chatgpt2api entrypoint") + } + for _, want := range []string{ + "ARG BUILD_NODE_OPTIONS=--max-old-space-size=1024", + "ARG BUILD_GOMAXPROCS=2", + "ARG BUILD_GOMEMLIMIT=2GiB", + } { + if !strings.Contains(dockerfile, want) { + t.Fatalf("deploy/Dockerfile must keep safe direct-build default %q", want) + } + } + + scriptData, err := os.ReadFile(filepath.Join("..", "..", "deploy", "docker-build-limited.sh")) + if err != nil { + t.Fatalf("read deploy/docker-build-limited.sh: %v", err) + } + script := string(scriptData) + if !strings.Contains(script, `--file "$repo_root/deploy/Dockerfile"`) { + t.Fatal("docker-build-limited.sh must build from deploy/Dockerfile") + } + if !strings.Contains(script, `-f "$repo_root/deploy/docker-compose.yml"`) { + t.Fatal("docker-build-limited.sh must run deploy/docker-compose.yml") + } + for _, want := range []string{ + `detect_cpu_count()`, + `detect_memory_mib()`, + `default_build_cpus=2`, + `default_build_memory=4g`, + `default_build_memory=3g`, + `default_buildkit_max_parallelism=1`, + `default_build_gomaxprocs=1`, + `build_cpus="${BUILD_CPUS:-$default_build_cpus}"`, + `buildkit_max_parallelism="${BUILDKIT_MAX_PARALLELISM:-$default_buildkit_max_parallelism}"`, + `export BUILD_GOMAXPROCS="${BUILD_GOMAXPROCS:-$default_build_gomaxprocs}"`, + `export BUILD_GOMEMLIMIT="${BUILD_GOMEMLIMIT:-$default_build_gomemlimit}"`, + } { + if !strings.Contains(script, want) { + t.Fatalf("docker-build-limited.sh must keep adaptive direct-run default %q", want) + } + } +} + func yamlListContains(config, value string) bool { for _, line := range strings.Split(config, "\n") { switch strings.TrimSpace(line) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3b1cf4c30..60cfad305 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -43,16 +43,12 @@ type LogMaintenanceBackend interface { DeleteLogsBefore(day string) (int, error) } -const LogEventsDocumentName = "logs/events.jsonl" - func NewBackendFromEnv(dataDir string) (Backend, error) { backendType := strings.ToLower(strings.TrimSpace(os.Getenv("STORAGE_BACKEND"))) if backendType == "" { backendType = "sqlite" } switch backendType { - case "json": - return NewJSONBackend(filepath.Join(dataDir, "accounts.json"), filepath.Join(dataDir, "auth_keys.json")), nil case "sqlite", "postgres", "postgresql", "mysql", "database": dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { @@ -64,223 +60,6 @@ func NewBackendFromEnv(dataDir string) (Backend, error) { } } -type JSONBackend struct { - dataDir string - filePath string - authKeysPath string -} - -func NewJSONBackend(filePath, authKeysPath string) *JSONBackend { - _ = os.MkdirAll(filepath.Dir(filePath), 0o755) - _ = os.MkdirAll(filepath.Dir(authKeysPath), 0o755) - return &JSONBackend{dataDir: filepath.Dir(filePath), filePath: filePath, authKeysPath: authKeysPath} -} - -func (b *JSONBackend) LoadAccounts() ([]map[string]any, error) { - return loadJSONList(b.filePath), nil -} - -func (b *JSONBackend) SaveAccounts(accounts []map[string]any) error { - return saveJSONValue(b.filePath, accounts) -} - -func (b *JSONBackend) LoadAuthKeys() ([]map[string]any, error) { - raw := loadJSONValue(b.authKeysPath) - if obj, ok := raw.(map[string]any); ok { - raw = obj["items"] - } - return anyListToMaps(raw), nil -} - -func (b *JSONBackend) SaveAuthKeys(keys []map[string]any) error { - return saveJSONValue(b.authKeysPath, map[string]any{"items": keys}) -} - -func (b *JSONBackend) HealthCheck() map[string]any { - if _, err := os.Stat(b.filePath); err != nil && !os.IsNotExist(err) { - return map[string]any{"status": "unhealthy", "backend": "json", "error": err.Error()} - } - return map[string]any{ - "status": "healthy", - "backend": "json", - "file_exists": exists(b.filePath), - "file_path": b.filePath, - "auth_keys_file_exists": exists(b.authKeysPath), - "auth_keys_file_path": b.authKeysPath, - } -} - -func (b *JSONBackend) Info() map[string]any { - return map[string]any{ - "type": "json", - "description": "本地 JSON 文件存储", - "file_path": b.filePath, - "file_exists": exists(b.filePath), - "auth_keys_file_path": b.authKeysPath, - "auth_keys_file_exists": exists(b.authKeysPath), - } -} - -func (b *JSONBackend) LoadJSONDocument(name string) (any, error) { - full, err := b.documentPath(name) - if err != nil { - return nil, err - } - data, err := os.ReadFile(full) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - return decodeJSONBytes(data) -} - -func (b *JSONBackend) SaveJSONDocument(name string, value any) error { - full, err := b.documentPath(name) - if err != nil { - return err - } - return saveJSONValue(full, value) -} - -func (b *JSONBackend) DeleteJSONDocument(name string) error { - full, err := b.documentPath(name) - if err != nil { - return err - } - removeErr := os.Remove(full) - if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { - return removeErr - } - removeEmptyParentDirs(b.dataDir, filepath.Dir(full)) - return nil -} - -func (b *JSONBackend) AppendLog(item map[string]any) error { - if item == nil { - item = map[string]any{} - } - item["type"] = "event" - full, err := b.documentPath(LogEventsDocumentName) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - return err - } - data, err := json.Marshal(item) - if err != nil { - return err - } - file, err := os.OpenFile(full, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer file.Close() - _, err = file.Write(append(data, '\n')) - return err -} - -func (b *JSONBackend) QueryLogs(startDate, endDate string, limit int) ([]map[string]any, error) { - full, err := b.documentPath(LogEventsDocumentName) - if err != nil { - return nil, err - } - data, err := os.ReadFile(full) - if errors.Is(err, os.ErrNotExist) { - return []map[string]any{}, nil - } - if err != nil { - return nil, err - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" { - return []map[string]any{}, nil - } - out := make([]map[string]any, 0) - for i := len(lines) - 1; i >= 0; i-- { - if limit > 0 && len(out) >= limit { - break - } - item, ok := decodeLogLine(lines[i]) - if !ok || !matchLogFilter(item, startDate, endDate) { - continue - } - out = append(out, item) - } - return out, nil -} - -func (b *JSONBackend) DeleteLogsBefore(day string) (int, error) { - day = strings.TrimSpace(day) - if day == "" { - return 0, nil - } - full, err := b.documentPath(LogEventsDocumentName) - if err != nil { - return 0, err - } - data, err := os.ReadFile(full) - if errors.Is(err, os.ErrNotExist) { - return 0, nil - } - if err != nil { - return 0, err - } - lines := strings.Split(strings.TrimRight(string(data), "\r\n"), "\n") - kept := make([]string, 0, len(lines)) - removed := 0 - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - item, ok := decodeLogLine(line) - if ok { - itemDay := logDay(strings.TrimSpace(fmt.Sprint(item["time"]))) - if itemDay != "" && itemDay < day { - removed++ - continue - } - } - kept = append(kept, line) - } - if removed == 0 { - return 0, nil - } - next := []byte{} - if len(kept) > 0 { - next = []byte(strings.Join(kept, "\n") + "\n") - } - if err := os.WriteFile(full, next, 0o644); err != nil { - return 0, err - } - return removed, nil -} - -func (b *JSONBackend) documentPath(name string) (string, error) { - rel, err := cleanDocumentName(name) - if err != nil { - return "", err - } - full := filepath.Join(b.dataDir, filepath.FromSlash(rel)) - root, err := filepath.Abs(b.dataDir) - if err != nil { - return "", err - } - resolved, err := filepath.Abs(full) - if err != nil { - return "", err - } - if resolved != root { - relToRoot, err := filepath.Rel(root, resolved) - if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) || filepath.IsAbs(relToRoot) { - return "", fmt.Errorf("invalid document name: %s", name) - } - } - return full, nil -} - type DatabaseBackend struct { databaseURL string driver string @@ -321,6 +100,13 @@ func (b *DatabaseBackend) configurePool() { b.db.SetMaxIdleConns(5) } +func (b *DatabaseBackend) Close() error { + if b == nil || b.db == nil { + return nil + } + return b.db.Close() +} + func (b *DatabaseBackend) configureSQLite() error { if b.driver != "sqlite" { return nil @@ -622,52 +408,6 @@ func (b *DatabaseBackend) placeholder(index int) string { return "?" } -func loadJSONList(path string) []map[string]any { - return anyListToMaps(loadJSONValue(path)) -} - -func loadJSONValue(path string) any { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - out, err := decodeJSONBytes(data) - if err != nil { - return nil - } - return out -} - -func saveJSONValue(path string, value any) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(data, '\n'), 0o644) -} - -func anyListToMaps(raw any) []map[string]any { - items, ok := raw.([]any) - if !ok { - return []map[string]any{} - } - out := make([]map[string]any, 0, len(items)) - for _, item := range items { - if m, ok := item.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -func exists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - func cleanDocumentName(name string) (string, error) { raw := strings.TrimSpace(filepath.ToSlash(name)) rel := path.Clean(raw) @@ -699,30 +439,6 @@ func decodeJSONBytes(data []byte) (any, error) { return out, nil } -func decodeLogLine(line string) (map[string]any, bool) { - line = strings.TrimSpace(line) - if line == "" { - return nil, false - } - raw, err := decodeJSONString(line) - if err != nil { - return nil, false - } - item, ok := raw.(map[string]any) - return item, ok -} - -func matchLogFilter(item map[string]any, startDate, endDate string) bool { - day := logDay(strings.TrimSpace(fmt.Sprint(item["time"]))) - if strings.TrimSpace(startDate) != "" && day < strings.TrimSpace(startDate) { - return false - } - if strings.TrimSpace(endDate) != "" && day > strings.TrimSpace(endDate) { - return false - } - return true -} - func logDay(value string) string { if len(value) < 10 { return "" @@ -730,27 +446,6 @@ func logDay(value string) string { return value[:10] } -func removeEmptyParentDirs(root, start string) { - rootAbs, err := filepath.Abs(root) - if err != nil { - return - } - current, err := filepath.Abs(start) - if err != nil { - return - } - for current != rootAbs { - rel, err := filepath.Rel(rootAbs, current) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { - return - } - if err := os.Remove(current); err != nil && !errors.Is(err, os.ErrNotExist) { - return - } - current = filepath.Dir(current) - } -} - func parseDatabaseURL(databaseURL string) (driver, dsn string, err error) { lower := strings.ToLower(databaseURL) switch { diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index f2063f633..7c9ca77de 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -3,6 +3,7 @@ package storage import ( "encoding/json" "path/filepath" + "strings" "testing" ) @@ -128,35 +129,6 @@ func TestDatabaseBackendDeletesLogsBeforeDay(t *testing.T) { } } -func TestJSONBackendDeletesLogsBeforeDay(t *testing.T) { - dir := t.TempDir() - backend := NewJSONBackend(filepath.Join(dir, "accounts.json"), filepath.Join(dir, "auth_keys.json")) - for _, item := range []map[string]any{ - {"time": "2026-04-28 10:00:00", "type": "event", "summary": "old"}, - {"time": "2026-04-29 10:00:00", "type": "event", "summary": "cutoff"}, - {"time": "2026-04-30 10:00:00", "type": "event", "summary": "new"}, - } { - if err := backend.AppendLog(item); err != nil { - t.Fatalf("AppendLog() error = %v", err) - } - } - - deleted, err := backend.DeleteLogsBefore("2026-04-29") - if err != nil { - t.Fatalf("DeleteLogsBefore() error = %v", err) - } - if deleted != 1 { - t.Fatalf("DeleteLogsBefore() deleted = %d, want 1", deleted) - } - logs, err := backend.QueryLogs("", "", 0) - if err != nil { - t.Fatalf("QueryLogs() error = %v", err) - } - if len(logs) != 2 { - t.Fatalf("remaining logs = %#v, want 2", logs) - } -} - func TestNewBackendFromEnvDefaultsToSQLiteProjectDatabase(t *testing.T) { dir := t.TempDir() t.Setenv("STORAGE_BACKEND", "") @@ -180,6 +152,19 @@ func TestNewBackendFromEnvDefaultsToSQLiteProjectDatabase(t *testing.T) { } } +func TestNewBackendFromEnvRejectsJSONBackend(t *testing.T) { + t.Setenv("STORAGE_BACKEND", "json") + t.Setenv("DATABASE_URL", "") + + _, err := NewBackendFromEnv(t.TempDir()) + if err == nil { + t.Fatal("NewBackendFromEnv() succeeded, want error") + } + if !strings.Contains(err.Error(), "unknown storage backend: json") { + t.Fatalf("NewBackendFromEnv() error = %v", err) + } +} + func TestDocumentNameValidation(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "chatgpt2api.db") backend, err := NewDatabaseBackend("sqlite:///" + filepath.ToSlash(dbPath)) diff --git a/internal/toolcall/choice.go b/internal/toolcall/choice.go new file mode 100644 index 000000000..b518aebd1 --- /dev/null +++ b/internal/toolcall/choice.go @@ -0,0 +1,47 @@ +package toolcall + +import "strings" + +func PolicyFromToolChoice(choice any) ChoicePolicy { + switch v := choice.(type) { + case nil: + return ChoicePolicy{Mode: ChoiceAuto} + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case ChoiceNone: + return ChoicePolicy{Mode: ChoiceNone} + case ChoiceRequired: + return ChoicePolicy{Mode: ChoiceRequired} + case ChoiceAuto: + return ChoicePolicy{Mode: ChoiceAuto} + default: + return ChoicePolicy{Mode: ChoiceAuto} + } + case map[string]any: + kind := strings.ToLower(strings.TrimSpace(asString(v["type"]))) + switch kind { + case "function": + if fn, ok := v["function"].(map[string]any); ok { + if name := strings.TrimSpace(asString(fn["name"])); name != "" { + return ChoicePolicy{Mode: ChoiceForced, Name: name} + } + } + case "tool": + if name := strings.TrimSpace(asString(v["name"])); name != "" { + return ChoicePolicy{Mode: ChoiceForced, Name: name} + } + case "any": + return ChoicePolicy{Mode: ChoiceRequired} + case "auto": + return ChoicePolicy{Mode: ChoiceAuto} + case "none": + return ChoicePolicy{Mode: ChoiceNone} + } + } + return ChoicePolicy{Mode: ChoiceAuto} +} + +func asString(v any) string { + s, _ := v.(string) + return s +} diff --git a/internal/toolcall/format.go b/internal/toolcall/format.go new file mode 100644 index 000000000..7f917830b --- /dev/null +++ b/internal/toolcall/format.go @@ -0,0 +1,53 @@ +package toolcall + +import ( + "fmt" +) + +func openAIToolCallID(index int, name string) string { + return fmt.Sprintf("call_%d_%s", index, name) +} + +func FormatOpenAI(calls []ParsedCall) []map[string]any { + out := make([]map[string]any, 0, len(calls)) + for i, call := range calls { + out = append(out, map[string]any{ + "id": openAIToolCallID(i, call.Name), + "type": "function", + "function": map[string]any{ + "name": call.Name, + "arguments": compactJSON(call.Input), + }, + }) + } + return out +} + +func FormatOpenAIStream(calls []ParsedCall) []map[string]any { + out := make([]map[string]any, 0, len(calls)) + for i, call := range calls { + out = append(out, map[string]any{ + "index": i, + "id": openAIToolCallID(i, call.Name), + "type": "function", + "function": map[string]any{ + "name": call.Name, + "arguments": compactJSON(call.Input), + }, + }) + } + return out +} + +func FormatAnthropic(calls []ParsedCall) []map[string]any { + out := make([]map[string]any, 0, len(calls)) + for i, call := range calls { + out = append(out, map[string]any{ + "type": "tool_use", + "id": fmt.Sprintf("toolu_%d_%s", i, call.Name), + "name": call.Name, + "input": cloneMap(call.Input), + }) + } + return out +} diff --git a/internal/toolcall/parse.go b/internal/toolcall/parse.go new file mode 100644 index 000000000..db4f0665d --- /dev/null +++ b/internal/toolcall/parse.go @@ -0,0 +1,385 @@ +package toolcall + +import ( + "encoding/json" + "encoding/xml" + "errors" + "io" + "strings" +) + +var supportedRoots = []string{"= 0 && (idx < 0 || pos < idx) { + idx = pos + } + } + if idx < 0 { + return text + } + return strings.TrimSpace(text[:idx]) +} + +func applyPolicy(calls []ParsedCall, rawCalls []ParsedCall, visible string, policy ChoicePolicy) ([]ParsedCall, string, error) { + switch policy.Mode { + case ChoiceRequired: + if len(calls) == 0 { + return nil, visible, errors.New("tool_choice required but no valid tool call was produced") + } + case ChoiceForced: + if policy.Name != "" { + for _, call := range rawCalls { + if call.Name != policy.Name { + return nil, visible, errors.New("tool_choice forced " + policy.Name + " but model produced " + call.Name) + } + } + calls = filterCalls(calls, []string{policy.Name}) + } + if len(calls) == 0 { + return nil, visible, errors.New("tool_choice required but no valid tool call was produced") + } + } + return calls, visible, nil +} + +func collectCalls(root *xmlNode) []ParsedCall { + var nodes []*xmlNode + switch root.Name { + case "tool_calls": + for _, child := range root.Children { + if child.Name == "tool_call" || child.Name == "function_call" || child.Name == "invoke" { + nodes = append(nodes, child) + } + } + case "tool_call", "function_call", "invoke": + nodes = append(nodes, root) + } + + calls := make([]ParsedCall, 0, len(nodes)) + for _, node := range nodes { + call, ok := parseCall(node) + if !ok || call.Name == "" { + continue + } + calls = append(calls, call) + } + return calls +} + +func filterCalls(calls []ParsedCall, availableNames []string) []ParsedCall { + if len(availableNames) == 0 { + return calls + } + allowed := make(map[string]struct{}, len(availableNames)) + for _, name := range availableNames { + name = strings.TrimSpace(name) + if name != "" { + allowed[name] = struct{}{} + } + } + out := make([]ParsedCall, 0, len(calls)) + for _, call := range calls { + if _, ok := allowed[call.Name]; ok { + out = append(out, call) + } + } + return out +} + +func parseCall(node *xmlNode) (ParsedCall, bool) { + name := strings.TrimSpace(node.Attrs["name"]) + if name == "" { + name = childScalar(node, "tool_name") + } + if name == "" { + name = childScalar(node, "name") + } + if name == "" { + return ParsedCall{}, false + } + + for _, key := range []string{"parameters", "params", "arguments", "input"} { + if child := firstChild(node, key); child != nil { + if input, ok := parseParamsNode(child); ok { + return ParsedCall{Name: name, Input: input}, true + } + return ParsedCall{Name: name, Input: map[string]any{}}, true + } + } + + input := map[string]any{} + for _, child := range node.Children { + if child.Name == "tool_name" || child.Name == "name" { + continue + } + mergeField(input, child.Name, parseNodeValue(child)) + } + return ParsedCall{Name: name, Input: input}, true +} + +func parseParamsNode(node *xmlNode) (map[string]any, bool) { + text := strings.TrimSpace(node.Text) + if text != "" { + var out map[string]any + if err := json.Unmarshal([]byte(text), &out); err == nil && out != nil { + return out, true + } + } + if len(node.Children) == 0 { + return map[string]any{}, true + } + out := map[string]any{} + for _, child := range node.Children { + mergeField(out, child.Name, parseNodeValue(child)) + } + return out, true +} + +func parseNodeValue(node *xmlNode) any { + if len(node.Children) == 0 { + return parseScalar(node.Text) + } + out := map[string]any{} + for _, child := range node.Children { + mergeField(out, child.Name, parseNodeValue(child)) + } + return out +} + +func mergeField(dst map[string]any, key string, value any) { + if existing, ok := dst[key]; ok { + switch items := existing.(type) { + case []any: + dst[key] = append(items, value) + default: + dst[key] = []any{existing, value} + } + return + } + dst[key] = value +} + +func parseScalar(text string) any { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + var value any + if err := json.Unmarshal([]byte(text), &value); err == nil { + return value + } + return text +} + +func childScalar(node *xmlNode, name string) string { + child := firstChild(node, name) + if child == nil { + return "" + } + return strings.TrimSpace(child.Text) +} + +func firstChild(node *xmlNode, name string) *xmlNode { + for _, child := range node.Children { + if child.Name == name { + return child + } + } + return nil +} + +type markupRange struct { + start int + end int +} + +func firstMarkup(text string) (string, bool) { + rng, ok := firstMarkupRange(text) + if !ok { + return "", false + } + return text[rng.start:rng.end], true +} + +func firstMarkupRange(text string) (markupRange, bool) { + ranges := markupRanges(text) + if len(ranges) == 0 { + return markupRange{}, false + } + return ranges[0], true +} + +func markupRanges(text string) []markupRange { + masked := maskFencedBlocks(text) + ranges := []markupRange{} + for offset := 0; offset < len(text); { + idx := nextMarkupStart(masked, offset) + if idx < 0 { + break + } + segment, ok := extractMarkup(text[idx:]) + if !ok { + offset = idx + 1 + continue + } + rng := markupRange{start: idx, end: idx + len(segment)} + ranges = append(ranges, rng) + offset = rng.end + } + return ranges +} + +func nextMarkupStart(masked string, offset int) int { + idx := -1 + for _, root := range supportedRoots { + if pos := strings.Index(masked[offset:], root); pos >= 0 { + pos += offset + if idx < 0 || pos < idx { + idx = pos + } + } + } + return idx +} + +func extractMarkup(text string) (string, bool) { + dec := xml.NewDecoder(strings.NewReader(text)) + for { + tok, err := dec.Token() + if err != nil { + return "", false + } + if start, ok := tok.(xml.StartElement); ok { + depth := 1 + for depth > 0 { + tok, err = dec.Token() + if err != nil { + return "", false + } + switch tok.(type) { + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + } + } + _ = start + return text[:dec.InputOffset()], true + } + } +} + +func parseXML(text string) (*xmlNode, error) { + dec := xml.NewDecoder(strings.NewReader(text)) + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + if start, ok := tok.(xml.StartElement); ok { + return readNode(dec, start) + } + } +} + +func readNode(dec *xml.Decoder, start xml.StartElement) (*xmlNode, error) { + node := &xmlNode{ + Name: start.Name.Local, + Attrs: map[string]string{}, + } + for _, attr := range start.Attr { + node.Attrs[attr.Name.Local] = strings.TrimSpace(attr.Value) + } + for { + tok, err := dec.Token() + if err != nil { + if err == io.EOF { + return node, nil + } + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + child, err := readNode(dec, t) + if err != nil { + return nil, err + } + node.Children = append(node.Children, child) + case xml.CharData: + node.Text += string(t) + case xml.EndElement: + if t.Name.Local == start.Name.Local { + node.Text = strings.TrimSpace(node.Text) + return node, nil + } + } + } +} + +func maskFencedBlocks(text string) string { + var b strings.Builder + b.Grow(len(text)) + inFence := false + for i := 0; i < len(text); { + if strings.HasPrefix(text[i:], "```") { + inFence = !inFence + b.WriteString(" ") + i += 3 + continue + } + if inFence { + b.WriteByte(' ') + } else { + b.WriteByte(text[i]) + } + i++ + } + return b.String() +} diff --git a/internal/toolcall/prompt.go b/internal/toolcall/prompt.go new file mode 100644 index 000000000..d6714e36a --- /dev/null +++ b/internal/toolcall/prompt.go @@ -0,0 +1,129 @@ +package toolcall + +import ( + "encoding/json" + "strings" +) + +func BuildPrompt(tools any, policy ChoicePolicy) string { + if policy.Mode == ChoiceNone { + return "" + } + + metas := toolMetas(tools) + if policy.Mode == ChoiceForced && policy.Name != "" { + filtered := metas[:0] + for _, meta := range metas { + if meta.Name == policy.Name { + filtered = append(filtered, meta) + } + } + metas = filtered + } + if len(metas) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("Use XML tool calls when needed.\n") + if policy.Mode == ChoiceRequired || policy.Mode == ChoiceForced { + b.WriteString("You must call a tool before answering.\n") + } + b.WriteString("Format: NAME{JSON}\n") + for _, meta := range metas { + b.WriteString("Tool: ") + b.WriteString(meta.Name) + b.WriteByte('\n') + if meta.Description != "" { + b.WriteString("Description: ") + b.WriteString(meta.Description) + b.WriteByte('\n') + } + b.WriteString("") + b.WriteString(meta.Name) + b.WriteString("") + if schemaJSON := compactJSON(meta.Schema); schemaJSON != "" { + b.WriteString(schemaJSON) + } else { + b.WriteString("{}") + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func ExtractToolMeta(tool map[string]any) (string, string, any) { + if fn, ok := tool["function"].(map[string]any); ok { + return strings.TrimSpace(asString(fn["name"])), strings.TrimSpace(asString(fn["description"])), firstNonNil(fn["parameters"], fn["inputSchema"], fn["schema"]) + } + return strings.TrimSpace(asString(tool["name"])), strings.TrimSpace(asString(tool["description"])), firstNonNil(tool["input_schema"], tool["inputSchema"], tool["schema"], tool["parameters"]) +} + +func ToolNames(tools any) []string { + metas := toolMetas(tools) + out := make([]string, 0, len(metas)) + for _, meta := range metas { + if meta.Name != "" { + out = append(out, meta.Name) + } + } + return out +} + +type ToolMeta struct { + Name string + Description string + Schema any +} + +func toolMetas(tools any) []ToolMeta { + items := toToolMaps(tools) + out := make([]ToolMeta, 0, len(items)) + for _, item := range items { + name, description, schema := ExtractToolMeta(item) + if name == "" { + continue + } + out = append(out, ToolMeta{Name: name, Description: description, Schema: schema}) + } + return out +} + +func toToolMaps(tools any) []map[string]any { + switch v := tools.(type) { + case []map[string]any: + return v + case []any: + out := make([]map[string]any, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out + case map[string]any: + return []map[string]any{v} + default: + return nil + } +} + +func compactJSON(v any) string { + if v == nil { + return "" + } + buf, err := json.Marshal(v) + if err != nil { + return "" + } + return string(buf) +} + +func firstNonNil(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} diff --git a/internal/toolcall/schema.go b/internal/toolcall/schema.go new file mode 100644 index 000000000..e32207bb8 --- /dev/null +++ b/internal/toolcall/schema.go @@ -0,0 +1,95 @@ +package toolcall + +import "encoding/json" + +func NormalizeForSchemas(calls []ParsedCall, tools any) []ParsedCall { + if len(calls) == 0 { + return nil + } + schemas := map[string]any{} + for _, meta := range toolMetas(tools) { + schemas[meta.Name] = meta.Schema + } + + out := make([]ParsedCall, 0, len(calls)) + for _, call := range calls { + normalized := ParsedCall{Name: call.Name, Input: cloneMap(call.Input)} + if schema, ok := schemas[call.Name]; ok { + if schemaMap, ok := schema.(map[string]any); ok { + if input, ok := normalizeValue(normalized.Input, schemaMap).(map[string]any); ok { + normalized.Input = input + } + } + } + out = append(out, normalized) + } + return out +} + +func normalizeValue(value any, schema map[string]any) any { + if schema == nil { + return value + } + if shouldJSONStringify(schema) { + if _, ok := value.(string); ok { + return value + } + if buf, err := json.Marshal(value); err == nil { + return string(buf) + } + return value + } + + typeName := asString(schema["type"]) + if typeName == "object" { + obj, ok := value.(map[string]any) + if !ok { + return value + } + out := cloneMap(obj) + props, _ := schema["properties"].(map[string]any) + for key, prop := range props { + propSchema, ok := prop.(map[string]any) + if !ok { + continue + } + current, exists := out[key] + if !exists { + continue + } + out[key] = normalizeValue(current, propSchema) + } + return out + } + return value +} + +func shouldJSONStringify(schema map[string]any) bool { + if asString(schema["type"]) == "string" { + return true + } + if constValue, ok := schema["const"]; ok { + _, isString := constValue.(string) + return isString + } + if enumValues, ok := schema["enum"].([]any); ok && len(enumValues) > 0 { + for _, item := range enumValues { + if _, isString := item.(string); !isString { + return false + } + } + return true + } + return false +} + +func cloneMap(src map[string]any) map[string]any { + if src == nil { + return map[string]any{} + } + out := make(map[string]any, len(src)) + for key, value := range src { + out[key] = value + } + return out +} diff --git a/internal/toolcall/toolcall_test.go b/internal/toolcall/toolcall_test.go new file mode 100644 index 000000000..498cf57c5 --- /dev/null +++ b/internal/toolcall/toolcall_test.go @@ -0,0 +1,422 @@ +package toolcall + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseXMLToolCallsWithCDATAAndNumbers(t *testing.T) { + text := "先处理\nread_file5" + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if visible != "先处理" { + t.Fatalf("visible = %q, want 先处理", visible) + } + if len(calls) != 1 { + t.Fatalf("len(calls) = %d, want 1 (%#v)", len(calls), calls) + } + if calls[0].Name != "read_file" { + t.Fatalf("calls[0].Name = %q, want read_file", calls[0].Name) + } + if got := calls[0].Input["path"]; got != "internal/app.go" { + t.Fatalf("path = %#v, want internal/app.go", got) + } + if got := calls[0].Input["limit"]; got != float64(5) { + t.Fatalf("limit = %#v, want float64(5)", got) + } +} + +func TestParseDirectSingularToolCall(t *testing.T) { + text := "prefix\nread_filea.go" + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if visible != "prefix" { + t.Fatalf("visible = %q, want prefix", visible) + } + if len(calls) != 1 { + t.Fatalf("len(calls) = %d, want 1 (%#v)", len(calls), calls) + } + if calls[0].Name != "read_file" { + t.Fatalf("calls[0].Name = %q, want read_file", calls[0].Name) + } + if got := calls[0].Input["path"]; got != "a.go" { + t.Fatalf("path = %#v, want a.go", got) + } +} + +func TestParseIgnoresFencedXML(t *testing.T) { + text := "```xml\nread_filea.go\n```" + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(calls) != 0 { + t.Fatalf("len(calls) = %d, want 0 (%#v)", len(calls), calls) + } + if visible != text { + t.Fatalf("visible = %q, want original fenced text", visible) + } +} + +func TestParseAndStripMarkupPreserveIdenticalFencedXML(t *testing.T) { + markup := "read_filea.go" + text := "示例:\n```xml\n" + markup + "\n```\n实际调用:\n" + markup + "\n结束" + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(calls) != 1 { + t.Fatalf("len(calls) = %d, want 1 (%#v)", len(calls), calls) + } + if calls[0].Name != "read_file" { + t.Fatalf("calls[0].Name = %q, want read_file", calls[0].Name) + } + wantVisible := "示例:\n```xml\n" + markup + "\n```\n实际调用:\n\n结束" + if visible != wantVisible { + t.Fatalf("visible = %q, want %q", visible, wantVisible) + } + + stripped := StripMarkup(text) + if stripped != wantVisible { + t.Fatalf("StripMarkup() = %q, want %q", stripped, wantVisible) + } +} + +func TestParseRepeatedFieldsAsArray(t *testing.T) { + text := "read_filea.gob.go" + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if visible != "" { + t.Fatalf("visible = %q, want empty", visible) + } + want := []any{"a.go", "b.go"} + if !reflect.DeepEqual(calls[0].Input["path"], want) { + t.Fatalf("path = %#v, want %#v", calls[0].Input["path"], want) + } +} + +func TestParseFunctionCallWithJSONObjectParams(t *testing.T) { + text := `read_file{"path":"a.go","limit":2}` + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if visible != "" { + t.Fatalf("visible = %q, want empty", visible) + } + if len(calls) != 1 { + t.Fatalf("len(calls) = %d, want 1", len(calls)) + } + if got := calls[0].Input["path"]; got != "a.go" { + t.Fatalf("path = %#v, want a.go", got) + } + if got := calls[0].Input["limit"]; got != float64(2) { + t.Fatalf("limit = %#v, want float64(2)", got) + } +} + +func TestParseInvokeWithXMLParams(t *testing.T) { + text := `a.go3` + + calls, _, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(calls) != 1 || calls[0].Name != "read_file" { + t.Fatalf("calls = %#v", calls) + } + if got := calls[0].Input["limit"]; got != float64(3) { + t.Fatalf("limit = %#v, want float64(3)", got) + } +} + +func TestRequiredPolicyErrorsWhenNoToolCall(t *testing.T) { + _, _, err := Parse("plain answer", []string{"read_file"}, ChoicePolicy{Mode: ChoiceRequired}) + if err == nil || err.Error() != "tool_choice required but no valid tool call was produced" { + t.Fatalf("err = %v", err) + } +} + +func TestForcedPolicyRejectsUnknownTool(t *testing.T) { + text := "write_filea.go" + + _, _, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceForced, Name: "read_file"}) + if err == nil || err.Error() != "tool_choice forced read_file but model produced write_file" { + t.Fatalf("err = %v", err) + } +} + +func TestForcedPolicyRejectsExtraToolCall(t *testing.T) { + text := "read_filea.gosearchgo" + + _, _, err := Parse(text, []string{"read_file", "search"}, ChoicePolicy{Mode: ChoiceForced, Name: "read_file"}) + if err == nil || err.Error() != "tool_choice forced read_file but model produced search" { + t.Fatalf("err = %v", err) + } +} + +func TestParseScansPastUnknownToolMarkup(t *testing.T) { + text := "before unknown{} middle read_filea.go after" + + calls, visible, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if visible != "before middle after" { + t.Fatalf("visible = %q, want stripped text", visible) + } + if len(calls) != 1 || calls[0].Name != "read_file" { + t.Fatalf("calls = %#v, want one read_file call", calls) + } + if got := calls[0].Input["path"]; got != "a.go" { + t.Fatalf("path = %#v, want a.go", got) + } +} + +func TestParseScansPastMalformedToolMarkup(t *testing.T) { + text := "bad broken ok read_filea.go" + + calls, _, err := Parse(text, []string{"read_file"}, ChoicePolicy{Mode: ChoiceAuto}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(calls) != 1 || calls[0].Name != "read_file" { + t.Fatalf("calls = %#v, want one read_file call", calls) + } +} + +func TestStreamableTextStopsBeforeToolMarkup(t *testing.T) { + text := "prefix\n" + if got := StreamableText(text); got != "prefix" { + t.Fatalf("StreamableText() = %q, want prefix", got) + } +} + +func TestPolicyFromToolChoice(t *testing.T) { + tests := []struct { + name string + choice any + want ChoicePolicy + }{ + { + name: "none string", + choice: "none", + want: ChoicePolicy{Mode: ChoiceNone}, + }, + { + name: "openai forced function", + choice: map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "read_file", + }, + }, + want: ChoicePolicy{Mode: ChoiceForced, Name: "read_file"}, + }, + { + name: "uppercase openai forced function", + choice: map[string]any{ + "type": "FUNCTION", + "function": map[string]any{ + "name": "read_file", + }, + }, + want: ChoicePolicy{Mode: ChoiceForced, Name: "read_file"}, + }, + { + name: "anthropic forced tool", + choice: map[string]any{ + "type": "tool", + "name": "search", + }, + want: ChoicePolicy{Mode: ChoiceForced, Name: "search"}, + }, + { + name: "uppercase anthropic forced tool", + choice: map[string]any{ + "type": "TOOL", + "name": "search", + }, + want: ChoicePolicy{Mode: ChoiceForced, Name: "search"}, + }, + { + name: "anthropic any object", + choice: map[string]any{ + "type": "any", + }, + want: ChoicePolicy{Mode: ChoiceRequired}, + }, + { + name: "anthropic auto object", + choice: map[string]any{ + "type": "auto", + }, + want: ChoicePolicy{Mode: ChoiceAuto}, + }, + { + name: "anthropic none object", + choice: map[string]any{ + "type": "none", + }, + want: ChoicePolicy{Mode: ChoiceNone}, + }, + { + name: "required string", + choice: "required", + want: ChoicePolicy{Mode: ChoiceRequired}, + }, + { + name: "uppercase none string", + choice: "NONE", + want: ChoicePolicy{Mode: ChoiceNone}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PolicyFromToolChoice(tt.choice); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("PolicyFromToolChoice() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestBuildPromptIncludesCompactToolSpec(t *testing.T) { + tools := []map[string]any{ + { + "type": "function", + "function": map[string]any{ + "name": "read_file", + "description": "Read a file", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + prompt := BuildPrompt(tools, ChoicePolicy{Mode: ChoiceRequired}) + for _, want := range []string{"Tool: read_file", "Description: Read a file", "", ""} { + if !strings.Contains(prompt, want) { + t.Fatalf("BuildPrompt() missing %q in %q", want, prompt) + } + } + if strings.Contains(strings.ToLower(prompt), "few-shot") { + t.Fatalf("BuildPrompt() should stay compact, got %q", prompt) + } +} + +func TestNormalizeForSchemasStringifiesObjectForStringSchema(t *testing.T) { + calls := []ParsedCall{{ + Name: "read_file", + Input: map[string]any{ + "payload": map[string]any{"a": float64(1)}, + }, + }} + tools := []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "read_file", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "payload": map[string]any{"type": "string"}, + }, + }, + }, + }} + + normalized := NormalizeForSchemas(calls, tools) + if got := normalized[0].Input["payload"]; got != `{"a":1}` { + t.Fatalf("normalized payload = %#v, want JSON string", got) + } +} + +func TestFormatOpenAI(t *testing.T) { + calls := []ParsedCall{{ + Name: "read_file", + Input: map[string]any{"path": "a.go", "limit": 2}, + }} + + got := FormatOpenAI(calls) + if len(got) != 1 { + t.Fatalf("len(FormatOpenAI()) = %d, want 1", len(got)) + } + if got[0]["type"] != "function" { + t.Fatalf("type = %#v, want function", got[0]["type"]) + } + id, ok := got[0]["id"].(string) + if !ok { + t.Fatalf("id = %#v, want string", got[0]["id"]) + } + if !strings.HasPrefix(id, "call_") { + t.Fatalf("id = %q, want prefix call_", id) + } + function, _ := got[0]["function"].(map[string]any) + if function["name"] != "read_file" { + t.Fatalf("name = %#v, want read_file", function["name"]) + } + if function["arguments"] != `{"limit":2,"path":"a.go"}` { + t.Fatalf("arguments = %#v, want JSON string", function["arguments"]) + } +} + +func TestFormatOpenAIStream(t *testing.T) { + calls := []ParsedCall{{ + Name: "read_file", + Input: map[string]any{"path": "a.go"}, + }} + + got := FormatOpenAIStream(calls) + if len(got) != 1 { + t.Fatalf("len(FormatOpenAIStream()) = %d, want 1", len(got)) + } + if got[0]["index"] != 0 { + t.Fatalf("index = %#v, want 0", got[0]["index"]) + } + delta, _ := got[0]["function"].(map[string]any) + if delta["arguments"] != `{"path":"a.go"}` { + t.Fatalf("arguments = %#v, want JSON string", delta["arguments"]) + } +} + +func TestFormatAnthropic(t *testing.T) { + calls := []ParsedCall{{ + Name: "search", + Input: map[string]any{"query": "golang"}, + }} + + got := FormatAnthropic(calls) + if len(got) != 1 { + t.Fatalf("len(FormatAnthropic()) = %d, want 1", len(got)) + } + if got[0]["type"] != "tool_use" { + t.Fatalf("type = %#v, want tool_use", got[0]["type"]) + } + if got[0]["name"] != "search" { + t.Fatalf("name = %#v, want search", got[0]["name"]) + } + if _, ok := got[0]["id"].(string); !ok { + t.Fatalf("id = %#v, want string", got[0]["id"]) + } + input, _ := got[0]["input"].(map[string]any) + if !reflect.DeepEqual(input, map[string]any{"query": "golang"}) { + t.Fatalf("input = %#v, want %#v", input, map[string]any{"query": "golang"}) + } +} diff --git a/internal/toolcall/types.go b/internal/toolcall/types.go new file mode 100644 index 000000000..f78aad15b --- /dev/null +++ b/internal/toolcall/types.go @@ -0,0 +1,18 @@ +package toolcall + +const ( + ChoiceAuto = "auto" + ChoiceNone = "none" + ChoiceRequired = "required" + ChoiceForced = "forced" +) + +type ParsedCall struct { + Name string + Input map[string]any +} + +type ChoicePolicy struct { + Mode string + Name string +} diff --git a/internal/util/upstream_error.go b/internal/util/upstream_error.go index e40dd9033..8307b47fa 100644 --- a/internal/util/upstream_error.go +++ b/internal/util/upstream_error.go @@ -10,7 +10,8 @@ func SummarizeUpstreamConnectionError(message string) (string, bool) { return "", false } lower := strings.ToLower(text) - if strings.Contains(lower, "utls.handshakecontext") || + if strings.Contains(lower, strings.ToLower(UpstreamConnectionFailureMessage)) || + strings.Contains(lower, "utls.handshakecontext") || strings.Contains(lower, "http/2 request failed") || strings.Contains(lower, "http/1.1 fallback failed") || strings.Contains(lower, "tls connect error") || diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 000000000..51e72de31 --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react"], + "categories": { + "correctness": "error" + }, + "env": { + "browser": true, + "es2020": true + }, + "ignorePatterns": ["**/.next/**", "**/dist/**", "**/node_modules/**", "**/out/**"], + "rules": { + "no-unused-vars": "off", + "typescript/no-explicit-any": "off", + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "react/only-export-components": [ + "warn", + { + "allowConstantExport": true, + "allowExportNames": ["badgeVariants", "buttonVariants"] + } + ] + } +} diff --git a/web/bun.lock b/web/bun.lock index 26e8880cc..3a0e32d1c 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -29,24 +29,18 @@ "zustand": "^5.0.8", }, "devDependencies": { - "@eslint/js": "^9", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@umijs/openapi": "^1.13.15", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", + "oxlint": "^1.63.0", "prettier-plugin-organize-imports": "^4.2.0", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4", "tw-animate-css": "^1.3.4", "typescript": "^5", - "typescript-eslint": "^8.59.1", "vite": "^8.0.10", }, }, @@ -62,95 +56,77 @@ "@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@babel/parser": ["@babel/parser@7.29.2", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "https://registry.npmmirror.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], - "@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@eslint/config-array": ["@eslint/config-array@0.20.1", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.20.1.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@eslint/core": ["@eslint/core@0.14.0", "https://registry.npmmirror.com/@eslint/core/-/core-0.14.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + "@oxc-project/types": ["@oxc-project/types@0.127.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], - "@eslint/js": ["@eslint/js@9.29.0", "https://registry.npmmirror.com/@eslint/js/-/js-9.29.0.tgz", {}, "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g=="], - "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "https://registry.npmmirror.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw=="], - "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g=="], - "@humanfs/node": ["@humanfs/node@0.16.6", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.6.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ=="], - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA=="], - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA=="], - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - - "@oxc-project/types": ["@oxc-project/types@0.127.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -278,48 +254,18 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@20.19.1", "https://registry.npmmirror.com/@types/node/-/node-20.19.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], "@types/react": ["@types/react@19.1.12", "https://registry.npmmirror.com/@types/react/-/react-19.1.12.tgz", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], "@types/react-dom": ["@types/react-dom@19.1.9", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.1.9.tgz", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.1.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.1.tgz", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], - "@umijs/openapi": ["@umijs/openapi@1.13.15", "https://registry.npmmirror.com/@umijs/openapi/-/openapi-1.13.15.tgz", { "dependencies": { "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "dayjs": "^1.10.3", "glob": "^7.1.6", "lodash": "^4.17.21", "memoizee": "^0.4.15", "mock.js": "^0.2.0", "mockjs": "^1.1.0", "node-fetch": "^2.6.1", "number-to-words": "^1.2.4", "nunjucks": "^3.2.2", "openapi3-ts": "^2.0.1", "prettier": "^2.2.1", "reserved-words": "^0.1.2", "rimraf": "^3.0.2", "swagger2openapi": "^7.0.4", "tiny-pinyin": "^1.3.2" }, "bin": { "openapi2ts": "dist/cli.js" } }, "sha512-+oJBEXV9Liu7tZzkYANs72hXiwqEngVhpUQN+XLVsAr49+D6thr+Fyb0cezcrydulOSsxa+VPaPMXPmXbAVuYA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "https://registry.npmmirror.com/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], - "acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -336,20 +282,14 @@ "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], - "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-me-maybe": ["call-me-maybe@1.0.2", "https://registry.npmmirror.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], "callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001791", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], - "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@3.0.0", "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -370,14 +310,10 @@ "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cosmiconfig": ["cosmiconfig@9.0.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], - "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.1.3", "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "d": ["d@1.0.2", "https://registry.npmmirror.com/d/-/d-1.0.2.tgz", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], @@ -388,10 +324,6 @@ "dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], - "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -400,8 +332,6 @@ "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.344", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], - "emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "enhanced-resolve": ["enhanced-resolve@5.18.2", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], @@ -430,54 +360,16 @@ "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@9.29.0", "https://registry.npmmirror.com/eslint/-/eslint-9.29.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="], - - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "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" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], - - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], - - "eslint-scope": ["eslint-scope@8.4.0", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "esniff": ["esniff@2.0.1", "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], - "espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "esquery": ["esquery@1.6.0", "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], - - "esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "event-emitter": ["event-emitter@0.3.5", "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], "ext": ["ext@1.7.0", "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -490,8 +382,6 @@ "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -502,10 +392,6 @@ "glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@17.5.0", "https://registry.npmmirror.com/globals/-/globals-17.5.0.tgz", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], - "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -518,60 +404,32 @@ "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "http2-client": ["http2-client@1.3.5", "https://registry.npmmirror.com/http2-client/-/http2-client-1.3.5.tgz", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="], - "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immer": ["immer@10.1.3", "https://registry.npmmirror.com/immer/-/immer-10.1.3.tgz", {}, "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw=="], "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-promise": ["is-promise@2.2.2", "https://registry.npmmirror.com/is-promise/-/is-promise-2.2.2.tgz", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], - "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@2.4.2", "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz", { "bin": "lib/jiti-cli.mjs" }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], - "jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lie": ["lie@3.1.1", "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -602,14 +460,8 @@ "localforage": ["localforage@1.10.0", "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], - "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.21", "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - - "lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lru-queue": ["lru-queue@0.1.0", "https://registry.npmmirror.com/lru-queue/-/lru-queue-0.1.0.tgz", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], "lucide-react": ["lucide-react@0.523.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.523.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw=="], @@ -642,12 +494,8 @@ "motion-utils": ["motion-utils@12.36.0", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.36.0.tgz", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], - "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next-tick": ["next-tick@1.1.0", "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], "node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -656,8 +504,6 @@ "node-readfiles": ["node-readfiles@0.2.0", "https://registry.npmmirror.com/node-readfiles/-/node-readfiles-0.2.0.tgz", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="], - "node-releases": ["node-releases@2.0.38", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], - "number-to-words": ["number-to-words@1.2.4", "https://registry.npmmirror.com/number-to-words/-/number-to-words-1.2.4.tgz", {}, "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw=="], "nunjucks": ["nunjucks@3.2.4", "https://registry.npmmirror.com/nunjucks/-/nunjucks-3.2.4.tgz", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], @@ -676,30 +522,20 @@ "openapi3-ts": ["openapi3-ts@2.0.2", "https://registry.npmmirror.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz", { "dependencies": { "yaml": "^1.10.2" } }, "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw=="], - "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "oxlint": ["oxlint@1.63.0", "https://registry.npmmirror.com/oxlint/-/oxlint-1.63.0.tgz", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.63.0", "@oxlint/binding-android-arm64": "1.63.0", "@oxlint/binding-darwin-arm64": "1.63.0", "@oxlint/binding-darwin-x64": "1.63.0", "@oxlint/binding-freebsd-x64": "1.63.0", "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", "@oxlint/binding-linux-arm-musleabihf": "1.63.0", "@oxlint/binding-linux-arm64-gnu": "1.63.0", "@oxlint/binding-linux-arm64-musl": "1.63.0", "@oxlint/binding-linux-ppc64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-musl": "1.63.0", "@oxlint/binding-linux-s390x-gnu": "1.63.0", "@oxlint/binding-linux-x64-gnu": "1.63.0", "@oxlint/binding-linux-x64-musl": "1.63.0", "@oxlint/binding-openharmony-arm64": "1.63.0", "@oxlint/binding-win32-arm64-msvc": "1.63.0", "@oxlint/binding-win32-ia32-msvc": "1.63.0", "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg=="], "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@2.8.8", "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz", { "bin": "bin-prettier.js" }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "prettier-plugin-organize-imports": ["prettier-plugin-organize-imports@4.2.0", "https://registry.npmmirror.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.2.0.tgz", { "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", "vue-tsc": "^2.1.0 || 3" }, "optionalPeers": ["vue-tsc"] }, "sha512-Zdy27UhlmyvATZi67BTnLcKTo8fm6Oik59Sz6H64PgZJVs6NJpPD1mT240mmJn62c98/QaL+r3kx9Q3gRpDajg=="], @@ -708,8 +544,6 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.5", "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-day-picker": ["react-day-picker@9.14.0", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.14.0.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], @@ -744,14 +578,8 @@ "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "should": ["should@13.2.3", "https://registry.npmmirror.com/should/-/should-13.2.3.tgz", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="], "should-equal": ["should-equal@2.0.0", "https://registry.npmmirror.com/should-equal/-/should-equal-2.0.0.tgz", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="], @@ -772,8 +600,6 @@ "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "swagger2openapi": ["swagger2openapi@7.0.8", "https://registry.npmmirror.com/swagger2openapi/-/swagger2openapi-7.0.8.tgz", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "boast": "boast.js", "oas-validate": "oas-validate.js", "swagger2openapi": "swagger2openapi.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="], @@ -794,26 +620,16 @@ "tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tw-animate-css": ["tw-animate-css@1.3.4", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.3.4.tgz", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="], "type": ["type@2.7.3", "https://registry.npmmirror.com/type/-/type-2.7.3.tgz", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], - "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "typescript": ["typescript@5.8.3", "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "typescript-eslint": ["typescript-eslint@8.59.1", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.1.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], - "undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -824,10 +640,6 @@ "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -842,34 +654,10 @@ "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "zustand": ["zustand@5.0.8", "https://registry.npmmirror.com/zustand/-/zustand-5.0.8.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.1", "https://registry.npmmirror.com/@eslint/core/-/core-0.15.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="], - - "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.3.1.tgz", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.1.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "nunjucks/commander": ["commander@5.1.0", "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "prettier-plugin-tailwindcss/prettier": ["prettier@3.6.2", "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz", { "bin": "bin/prettier.cjs" }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], @@ -897,9 +685,5 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs deleted file mode 100644 index b2ba20b79..000000000 --- a/web/eslint.config.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import js from '@eslint/js'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import globals from 'globals'; -import prettier from 'eslint-config-prettier/flat'; -import tseslint from 'typescript-eslint'; - -const eslintConfig = [ - { ignores: ['**/.next/**', '**/dist/**', '**/node_modules/**', '**/out/**'] }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true, allowExportNames: ['badgeVariants', 'buttonVariants'] }, - ], - }, - }, - prettier, - { - rules: { - '@typescript-eslint/no-unused-vars': 'off', // 不检查未使用的变量 - '@typescript-eslint/no-explicit-any': 'off', // 关闭 any 报错 - }, - }, -]; - -export default eslintConfig; diff --git a/web/index.html b/web/index.html index a3c8f56d3..51a7739bd 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,8 @@ - + + ChatGptImage diff --git a/web/package.json b/web/package.json index 458cdd552..16de79012 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview --host 0.0.0.0", - "lint": "eslint ." + "lint": "oxlint" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", @@ -34,24 +34,18 @@ "zustand": "^5.0.8" }, "devDependencies": { - "@eslint/js": "^9", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@umijs/openapi": "^1.13.15", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", + "oxlint": "^1.63.0", "prettier-plugin-organize-imports": "^4.2.0", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4", "tw-animate-css": "^1.3.4", "typescript": "^5", - "typescript-eslint": "^8.59.1", "vite": "^8.0.10" }, "overrides": { diff --git a/web/public/favicon.ico b/web/public/favicon.ico index 718d6fea4..74d77c072 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/src/app/accounts/components/account-import-dialog.tsx b/web/src/app/accounts/components/account-import-dialog.tsx index cb912affd..bce296d40 100644 --- a/web/src/app/accounts/components/account-import-dialog.tsx +++ b/web/src/app/accounts/components/account-import-dialog.tsx @@ -26,13 +26,15 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; -import { createAccounts, type Account } from "@/lib/api"; +import { createAccountFromSession, createAccounts, type Account } from "@/lib/api"; import { cn } from "@/lib/utils"; type ImportMethod = "menu" | "token" | "session" | "cpa"; type AccountImportDialogProps = { disabled?: boolean; + canImportTokens: boolean; + canImportSession: boolean; onImported: (items: Account[]) => void; }; @@ -56,6 +58,11 @@ function getSessionAccessToken(value: unknown) { return typeof token === "string" ? token.trim() : ""; } +function getSessionToken(value: unknown) { + const token = (value as { sessionToken?: unknown })?.sessionToken; + return typeof token === "string" ? token.trim() : ""; +} + function getCpaAccessToken(value: unknown) { const token = (value as { access_token?: unknown })?.access_token; return typeof token === "string" ? token.trim() : ""; @@ -102,7 +109,7 @@ function MethodCard({ ); } -export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) { +export function AccountImportDialog({ disabled, canImportTokens, canImportSession, onImported }: AccountImportDialogProps) { const navigate = useNavigate(); const [open, setOpen] = useState(false); const [method, setMethod] = useState("menu"); @@ -196,24 +203,45 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo }; const handleImportSessionJson = async () => { - if (!sessionInput.trim()) { + const sessionJson = sessionInput.trim(); + if (!sessionJson) { toast.error("请先粘贴完整 Session JSON"); return; } try { - const payload = JSON.parse(sessionInput) as unknown; - const token = getSessionAccessToken(payload); + const payload = JSON.parse(sessionJson) as unknown; + const accessToken = getSessionAccessToken(payload); + const sessionToken = getSessionToken(payload); - if (!token) { + if (!accessToken) { toast.error("未从 Session JSON 中提取到 accessToken"); return; } + if (!sessionToken) { + toast.error("未从 Session JSON 中提取到 sessionToken"); + return; + } - await submitTokens([token], "Session JSON 导入完成"); + setIsSubmitting(true); + const data = await createAccountFromSession(sessionJson); + onImported(data.items); + setOpen(false); + resetState(); + + if ((data.errors?.length ?? 0) > 0) { + const firstError = data.errors?.[0]?.error; + toast.error( + `Session JSON 导入完成,新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,Session 刷新 ${data.session_refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`, + ); + } else { + toast.success("Session JSON 导入完成,已保存 sessionToken 并自动刷新账号信息"); + } } catch (error) { const message = error instanceof Error ? error.message : "Session JSON 解析失败"; toast.error(message); + } finally { + setIsSubmitting(false); } }; @@ -336,7 +364,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo {sessionUrl} - ,复制页面返回的完整 JSON,系统会自动提取其中的 `accessToken` 导入。 + ,复制页面返回的完整 JSON,系统会保存其中的 `accessToken` 和 `sessionToken`。
风险提示
@@ -405,44 +433,52 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo return (
- setMethod("token")} - /> - setMethod("session")} - /> - setMethod("cpa")} - /> - { - setOpen(false); - resetState(); - navigate("/settings"); - }} - /> - { - setOpen(false); - resetState(); - navigate("/settings"); - }} - /> + {canImportTokens ? ( + setMethod("token")} + /> + ) : null} + {canImportSession ? ( + setMethod("session")} + /> + ) : null} + {canImportTokens ? ( + <> + setMethod("cpa")} + /> + { + setOpen(false); + resetState(); + navigate("/settings"); + }} + /> + { + setOpen(false); + resetState(); + navigate("/settings"); + }} + /> + + ) : null}
); }; @@ -477,7 +513,7 @@ export function AccountImportDialog({ disabled, onImported }: AccountImportDialo : method === "token" ? "支持手动粘贴或从 TXT 文件导入,一行一个 Token。" : method === "session" - ? "粘贴完整 Session JSON,系统会自动提取 accessToken。" + ? "粘贴完整 Session JSON,系统会保存 accessToken 和 sessionToken。" : "支持一次读取多个本地 JSON 文件,并在提交前做数量确认。"} diff --git a/web/src/app/accounts/page.tsx b/web/src/app/accounts/page.tsx index f59e03d59..db9698dea 100644 --- a/web/src/app/accounts/page.tsx +++ b/web/src/app/accounts/page.tsx @@ -74,6 +74,8 @@ const accountStatusOptions: { label: string; value: AccountStatus | "all" }[] = { label: "正常", value: "正常" }, { label: "限流", value: "限流" }, { label: "异常", value: "异常" }, + { label: "刷新中", value: "刷新中" }, + { label: "过期待刷新", value: "过期待刷新" }, { label: "禁用", value: "禁用" }, ]; @@ -87,6 +89,8 @@ const statusMeta: Record< 正常: { icon: CheckCircle2, badge: "success" }, 限流: { icon: CircleAlert, badge: "warning" }, 异常: { icon: CircleOff, badge: "danger" }, + 刷新中: { icon: LoaderCircle, badge: "warning" }, + 过期待刷新: { icon: CircleAlert, badge: "warning" }, 禁用: { icon: Ban, badge: "secondary" }, }; @@ -265,7 +269,9 @@ function AccountsPageContent({ session }: { session: StoredAuthSession }) { const [isExporting, setIsExporting] = useState(false); const [refreshingAccountIds, setRefreshingAccountIds] = useState([]); - const canImportAccounts = hasAPIPermission(session, "POST", "/api/accounts"); + const canImportTokenAccounts = hasAPIPermission(session, "POST", "/api/accounts"); + const canImportSessionAccounts = hasAPIPermission(session, "POST", "/api/accounts/session"); + const canImportAccounts = canImportTokenAccounts || canImportSessionAccounts; const canRefreshAccounts = hasAPIPermission(session, "POST", "/api/accounts/refresh"); const canUpdateAccount = hasAPIPermission(session, "POST", "/api/accounts/update"); const canDeleteAccounts = hasAPIPermission(session, "DELETE", "/api/accounts"); @@ -617,6 +623,8 @@ function AccountsPageContent({ session }: { session: StoredAuthSession }) { {canImportAccounts ? ( { applyAccountItems(items); setSelectedIds([]); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index bd03d78e1..7aea20cbd 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -149,6 +149,9 @@ body[data-scroll-locked] { margin-right: 0 !important; } + body[data-scroll-locked][data-select-scroll-unlocked] { + overflow: visible !important; + } button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; diff --git a/web/src/app/image-manager/page.tsx b/web/src/app/image-manager/page.tsx index 6f96e652a..cda2b3372 100644 --- a/web/src/app/image-manager/page.tsx +++ b/web/src/app/image-manager/page.tsx @@ -1,15 +1,18 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Check, Copy, Download, Eye, Globe2, ImageIcon, LoaderCircle, Lock, MoreHorizontal, RefreshCw, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { Check, Copy, Download, Eye, Globe2, ImageIcon, LoaderCircle, Lock, MoreHorizontal, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, X } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { writeSimilarImageIntent } from "@/app/image/similar-image-intent"; import { AuthenticatedImage } from "@/components/authenticated-image"; import { DateRangeFilter } from "@/components/date-range-filter"; import { ImageLightbox } from "@/components/image-lightbox"; import { PageHeader } from "@/components/page-header"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -28,7 +31,11 @@ import { type ImageVisibility, type ManagedImage, } from "@/lib/api"; -import { fetchAuthenticatedImageBlob, shouldUseAuthenticatedImageFallback } from "@/lib/authenticated-image"; +import { + fetchAuthenticatedImageBlob, + invalidateAuthenticatedImageCacheForPaths, + shouldUseAuthenticatedImageFallback, +} from "@/lib/authenticated-image"; import { clearImageManagerCache, getImageManagerCache, @@ -41,7 +48,7 @@ import { import { formatImageFileSize } from "@/lib/image-size"; import { cn } from "@/lib/utils"; import { useAuthGuard } from "@/lib/use-auth-guard"; -import { hasAPIPermission, type StoredAuthSession } from "@/store/auth"; +import { canAccessPath, hasAPIPermission, type StoredAuthSession } from "@/store/auth"; function getManagedImageFormatLabel(item: ManagedImage) { const normalized = (item.name || item.url).split("?")[0]?.match(/\.([a-z0-9]+)$/i)?.[1] || "image"; @@ -101,6 +108,15 @@ type DeleteImageTarget = { paths: string[]; }; +type PublishImageTarget = { + items: ManagedImage[]; +}; + +type PublishRecipeOptions = { + sharePromptParameters: boolean; + shareReferenceImages: boolean; +}; + type ImageVisibilityFilter = "all" | ImageVisibility; type ImageFormatFilter = "all" | "png" | "jpg" | "webp" | "gif" | "other"; type ImageOrientationFilter = "all" | "landscape" | "portrait" | "square" | "unknown"; @@ -146,6 +162,22 @@ function imageOwnerLabel(item: ManagedImage) { return item.owner_name?.trim() || "未知用户"; } +function reusableImagePrompt(item: ManagedImage) { + return item.share_prompt_parameters && item.prompt?.trim() + ? item.prompt.trim() + : "参考这张图,生成一张风格、主体和构图相近的新图片。"; +} + +function reusableImageReferenceUrls(item: ManagedImage) { + if (!item.share_reference_images) { + return [item.url]; + } + const urls = item.reference_image_urls?.length + ? item.reference_image_urls + : item.reference_images?.map((reference) => reference.url || "").filter(Boolean); + return urls && urls.length > 0 ? Array.from(new Set(urls.map((url) => url.trim()).filter(Boolean))) : [item.url]; +} + function getManagedImageOrientation(item: ManagedImage): ImageOrientationFilter { if (!item.width || !item.height) { return "unknown"; @@ -313,6 +345,10 @@ function matchesManagedImageKeyword(item: ManagedImage, keyword: string) { item.url, item.owner_name, item.owner_id, + item.prompt, + item.model, + item.quality, + item.output_format, item.created_at, item.date, getManagedImageResolution(item), @@ -394,12 +430,15 @@ function useOrderedImageMasonryColumns(items: ManagedImage[]) { function ImageManagerContent({ cacheScope, canDeleteImages, + canGenerateSimilar, isAdmin, }: { cacheScope: string; canDeleteImages: boolean; + canGenerateSimilar: boolean; isAdmin: boolean; }) { + const navigate = useNavigate(); const activeLoadRef = useRef(null); const autoRefreshAbortRef = useRef(null); const loadMoreTargetRef = useRef(null); @@ -413,6 +452,11 @@ function ImageManagerContent({ const [selectedImageIds, setSelectedImageIds] = useState>({}); const [downloadingKey, setDownloadingKey] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); + const [publishRecipeOptions, setPublishRecipeOptions] = useState({ + sharePromptParameters: false, + shareReferenceImages: false, + }); const [isDeleting, setIsDeleting] = useState(false); const [visibilityMutatingPath, setVisibilityMutatingPath] = useState(null); const [focusedImagePath, setFocusedImagePath] = useState(null); @@ -750,6 +794,29 @@ function ImageManagerContent({ } }; + const handleGenerateSimilar = (item: ManagedImage) => { + if (!canGenerateSimilar) { + toast.error("当前账号没有创作台权限"); + return; + } + const sourceImageUrls = reusableImageReferenceUrls(item); + writeSimilarImageIntent({ + prompt: reusableImagePrompt(item), + sourceImageUrl: sourceImageUrls[0] || item.url, + sourceImageUrls, + sourceKind: sourceImageUrls[0] === item.url ? "public_image" : "original_references", + sourceImageName: item.name, + model: item.share_prompt_parameters ? item.model : undefined, + quality: item.share_prompt_parameters ? item.quality : undefined, + requestedSize: item.share_prompt_parameters ? item.requested_size : undefined, + resolutionPreset: item.share_prompt_parameters ? item.resolution_preset : undefined, + outputFormat: item.share_prompt_parameters ? item.output_format : undefined, + outputCompression: item.share_prompt_parameters ? item.output_compression : undefined, + }); + navigate("/image"); + toast.success(sourceImageUrls[0] === item.url ? "已使用公开图准备同款生成" : "已带入公开的原始参考图和生成参数"); + }; + const openDeleteConfirm = (targetItems: ManagedImage[]) => { if (!canDeleteImages) { return; @@ -773,6 +840,7 @@ function ImageManagerContent({ try { const data = await deleteManagedImages(paths); removeCachedManagedImages(paths); + invalidateAuthenticatedImageCacheForPaths(paths); setItems((current) => current.filter((item) => !pathSet.has(item.path))); setSelectedImageIds((current) => { const next = { ...current }; @@ -796,7 +864,20 @@ function ImageManagerContent({ } }; - const handleVisibilityChange = async (item: ManagedImage, visibility: ImageVisibility) => { + const openPublishConfirm = (targetItems: ManagedImage[]) => { + const pendingItems = targetItems.filter((item) => item.visibility !== "public"); + if (pendingItems.length === 0) { + return; + } + setPublishRecipeOptions({ sharePromptParameters: false, shareReferenceImages: false }); + setPublishTarget({ items: pendingItems }); + }; + + const handleVisibilityChange = async ( + item: ManagedImage, + visibility: ImageVisibility, + options: PublishRecipeOptions = { sharePromptParameters: false, shareReferenceImages: false }, + ) => { if (galleryView !== "mine" || visibilityMutatingPath) { return; } @@ -804,9 +885,13 @@ function ImageManagerContent({ if (previousVisibility === visibility) { return; } + if (visibility === "public" && !publishTarget) { + openPublishConfirm([item]); + return; + } setVisibilityMutatingPath(item.path); try { - const data = await updateManagedImageVisibility(item.path, visibility); + const data = await updateManagedImageVisibility(item.path, visibility, options); const updated = { ...data.item, path: item.path, @@ -833,7 +918,11 @@ function ImageManagerContent({ } }; - const handleBulkVisibilityChange = async (targetItems: ManagedImage[], visibility: ImageVisibility) => { + const handleBulkVisibilityChange = async ( + targetItems: ManagedImage[], + visibility: ImageVisibility, + options: PublishRecipeOptions = { sharePromptParameters: false, shareReferenceImages: false }, + ) => { if (galleryView !== "mine" || visibilityMutatingPath) { return; } @@ -841,12 +930,16 @@ function ImageManagerContent({ if (pendingItems.length === 0) { return; } + if (visibility === "public" && !publishTarget) { + openPublishConfirm(pendingItems); + return; + } setVisibilityMutatingPath(`bulk:${visibility}`); try { const results = await Promise.allSettled( pendingItems.map(async (item) => { - const data = await updateManagedImageVisibility(item.path, visibility); + const data = await updateManagedImageVisibility(item.path, visibility, options); return { ...data.item, path: item.path, @@ -882,6 +975,26 @@ function ImageManagerContent({ } }; + const handleConfirmPublish = async () => { + if (!publishTarget || visibilityMutatingPath) { + return; + } + const targetItems = publishTarget.items; + const options = { + sharePromptParameters: publishRecipeOptions.sharePromptParameters, + shareReferenceImages: publishRecipeOptions.sharePromptParameters && publishRecipeOptions.shareReferenceImages, + }; + try { + if (targetItems.length === 1) { + await handleVisibilityChange(targetItems[0], "public", options); + return; + } + await handleBulkVisibilityChange(targetItems, "public", options); + } finally { + setPublishTarget(null); + } + }; + useEffect(() => { void loadImages(); }, [loadImages]); @@ -1524,6 +1637,20 @@ function ImageManagerContent({ View Original + {galleryView === "public" && canGenerateSimilar ? ( + + ) : null} {galleryView !== "mine" ? ( + + + + + ) : null} {canDeleteImages && deleteTarget ? ( (!open && !isDeleting ? setDeleteTarget(null) : null)}> @@ -1699,5 +1893,13 @@ export default function ImageManagerPage() { return
; } const canDeleteImages = hasAPIPermission(session, "DELETE", "/api/images"); - return ; + const canGenerateSimilar = canAccessPath(session, "/image") && hasAPIPermission(session, "POST", "/api/creation-tasks"); + return ( + + ); } diff --git a/web/src/app/image/components/image-composer.tsx b/web/src/app/image/components/image-composer.tsx index 64bad65be..b1a6d917b 100644 --- a/web/src/app/image/components/image-composer.tsx +++ b/web/src/app/image/components/image-composer.tsx @@ -73,6 +73,9 @@ type ImageComposerProps = { imageOutputFormat: ImageOutputFormat; imageOutputCompression: string; highResolutionHint?: ReactNode; + billingSummary: string; + estimatedBillingUnits: number; + billingBlocked: boolean; referenceImages: Array<{ name: string; dataUrl: string }>; textareaRef: RefObject; fileInputRef: RefObject; @@ -289,6 +292,9 @@ export function ImageComposer({ imageOutputFormat, imageOutputCompression, highResolutionHint, + billingSummary, + estimatedBillingUnits, + billingBlocked, referenceImages, textareaRef, fileInputRef, @@ -449,9 +455,6 @@ export function ImageComposer({ }, [isPromptAreaResizing]); const handleTextareaPaste = (event: ClipboardEvent) => { - if (composerMode === "chat") { - return; - } const imageFiles = getImageFiles(event.clipboardData.files); if (imageFiles.length === 0) { return; @@ -467,9 +470,6 @@ export function ImageComposer({ return; } - if (composerMode === "chat") { - onComposerModeChange("image"); - } void onReferenceImageChange(imageFiles); }; @@ -585,10 +585,6 @@ export function ImageComposer({ }; const handlePickReferenceImage = () => { - if (composerMode === "chat") { - onComposerModeChange("image"); - } - fileInputRef.current?.click(); }; @@ -617,7 +613,7 @@ export function ImageComposer({ }} /> - {composerMode === "image" && referenceImages.length > 0 ? ( + {referenceImages.length > 0 ? (
{referenceImages.map((image, index) => (
@@ -1130,15 +1126,22 @@ export function ImageComposer({
+
+ {billingSummary} + 预计消耗 {estimatedBillingUnits} 图片单位 +
diff --git a/web/src/app/image/components/image-results.tsx b/web/src/app/image/components/image-results.tsx index 81e0d3fc4..ad9d49144 100644 --- a/web/src/app/image/components/image-results.tsx +++ b/web/src/app/image/components/image-results.tsx @@ -9,7 +9,11 @@ import type { ImagePromptPreset } from "@/app/image/image-presets"; import { formatImageSizeDisplay, getImageSizeRequirementLabel, isHighResolutionImageSize } from "@/app/image/image-options"; import { IMAGE_MODEL_ROUTE_DETAILS, supportsImageOutputCompression } from "@/lib/api"; import type { ImageVisibility } from "@/lib/api"; -import { fetchAuthenticatedImageBlob, shouldUseAuthenticatedImageFallback } from "@/lib/authenticated-image"; +import { + fetchAuthenticatedImageBlob, + getCachedAuthenticatedImageByteSize, + shouldUseAuthenticatedImageFallback, +} from "@/lib/authenticated-image"; import { formatBase64ImageFileSize, formatImageFileSize } from "@/lib/image-size"; import { cn } from "@/lib/utils"; import { @@ -235,6 +239,10 @@ async function fetchImageSizeLabel(src: string) { if (!src || src.startsWith("data:")) { return ""; } + const cachedByteSize = getCachedAuthenticatedImageByteSize(src); + if (cachedByteSize > 0) { + return formatImageFileSize(cachedByteSize); + } try { const blob = shouldUseAuthenticatedImageFallback(src) @@ -708,6 +716,10 @@ export function ImageResults({ { updateImageDimensions( diff --git a/web/src/app/image/page.tsx b/web/src/app/image/page.tsx index cad178639..b2d21c3c5 100644 --- a/web/src/app/image/page.tsx +++ b/web/src/app/image/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; -import { History, ImagePlus, LoaderCircle, Plus, Trash2, X } from "lucide-react"; +import { Globe2, History, ImagePlus, LoaderCircle, Plus, Trash2, X } from "lucide-react"; import { toast } from "sonner"; import { ImageComposer } from "@/app/image/components/image-composer"; @@ -31,9 +31,11 @@ import { type ImageSizeSelection, } from "@/app/image/image-options"; import { IMAGE_PROMPT_PRESETS, type ImagePromptPreset } from "@/app/image/image-presets"; +import { consumeSimilarImageIntent } from "@/app/image/similar-image-intent"; import { ImageSidebar } from "@/app/image/components/image-sidebar"; import { ImageLightbox } from "@/components/image-lightbox"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -61,6 +63,7 @@ import { DEFAULT_CHAT_MODEL, DEFAULT_IMAGE_MODEL, fetchCreationTasks, + fetchProfile, IMAGE_CREATION_MODEL_OPTIONS, IMAGE_MODEL_ROUTE_DETAILS, IMAGE_OUTPUT_FORMAT_OPTIONS, @@ -77,11 +80,13 @@ import { type ImageOutputFormat, type CreationTask, type CreationTaskMessage, + type FallbackReferenceImage, type ImageVisibility, } from "@/lib/api"; import { fetchAuthenticatedImageBlob } from "@/lib/authenticated-image"; import { clearImageManagerCache } from "@/lib/image-manager-cache"; import { getManagedImagePathFromUrl } from "@/lib/image-path"; +import { authSessionFromLoginResponse, setVerifiedAuthSession } from "@/lib/session"; import { cn } from "@/lib/utils"; import { useAuthGuard } from "@/lib/use-auth-guard"; import { @@ -151,6 +156,17 @@ type EditingTurnDraft = { referenceImages: StoredReferenceImage[]; }; +type PublishImageTarget = { + conversationId: string; + turnId: string; + imageIndex: number; +}; + +type PublishRecipeOptions = { + sharePromptParameters: boolean; + shareReferenceImages: boolean; +}; + type CreationTaskDataItem = NonNullable[number]; function buildConversationTitle(prompt: string) { @@ -261,6 +277,17 @@ function getPromptReferenceImageUrls(prompt: BananaPrompt) { return Array.from(new Set(urls.map((url) => url.trim()).filter(Boolean))); } +function reusableOutputCompressionValue(value: unknown, outputFormat: ImageOutputFormat) { + if (!supportsImageOutputCompression(outputFormat)) { + return ""; + } + const compression = Number(value); + if (!Number.isFinite(compression)) { + return ""; + } + return String(Math.min(100, Math.max(0, Math.round(compression)))); +} + async function buildReferenceImageFromStoredImage(image: StoredImage, fileName: string) { const direct = buildReferenceImageFromResult(image, fileName); if (direct) { @@ -447,12 +474,12 @@ function updateStoredImage(image: StoredImage, updates: Partial): S return STORED_IMAGE_FIELDS.every((field) => image[field] === next[field]) ? image : next; } -function creationTaskImageStatus(task: CreationTask, dataIndex = 0): "queued" | "running" | "success" | undefined { +function creationTaskImageStatus(task: CreationTask, dataIndex = 0): "queued" | "running" | "success" | "error" | "cancelled" | undefined { const outputStatus = task.output_statuses?.[dataIndex]; - if (outputStatus === "queued" || outputStatus === "running" || outputStatus === "success") { + if (outputStatus === "queued" || outputStatus === "running" || outputStatus === "success" || outputStatus === "error" || outputStatus === "cancelled") { return outputStatus; } - if (task.status === "queued" || task.status === "running" || task.status === "success") { + if (task.status === "queued" || task.status === "running" || task.status === "success" || task.status === "error" || task.status === "cancelled") { return task.status; } return undefined; @@ -498,6 +525,15 @@ function taskDataToStoredImage(image: StoredImage, task: CreationTask, dataIndex const item = task.data?.[dataIndex]; if (!item?.b64_json && !item?.url) { if (dataIndex > 0 && image.taskId !== image.id) { + const slotStatus = creationTaskImageStatus(task, dataIndex); + if (slotStatus === "error" || slotStatus === "cancelled") { + return updateStoredImage(image, { + taskId: task.id, + taskStatus: slotStatus, + status: slotStatus === "cancelled" ? "cancelled" : "error", + error: slotStatus === "cancelled" ? task.error || "任务已终止" : formatCreationTaskErrorMessage(task.error || "生成失败"), + }); + } return updateStoredImage(image, { taskId: image.id, taskStatus: "queued", @@ -697,6 +733,12 @@ function formatCreationTaskErrorMessage(message: string) { } const normalized = trimmed.toLowerCase(); + if (normalized.includes("user balance insufficient")) { + return "用户余额不足"; + } + if (normalized.includes("user quota exceeded")) { + return "用户配额不足"; + } if (normalized.includes("an error occurred while processing your request")) { const requestId = trimmed.match(/request id\s+([a-z0-9-]+)/i)?.[1]; return [ @@ -724,6 +766,25 @@ function formatCreationTaskError(error: unknown, fallback = "生成图片失败" return formatCreationTaskErrorMessage(error instanceof Error ? error.message : String(error || fallback)); } +function formatBillingSummary(session: NonNullable["session"]>) { + const billing = session.billing; + if (!billing) { + return "本地额度 --"; + } + if (billing.unlimited) { + return "本地额度无限"; + } + if (billing.type === "subscription") { + return `订阅剩余 ${billing.available}`; + } + return `余额 ${billing.available}`; +} + +function hasEnoughBilling(session: NonNullable["session"]>, estimated: number) { + const billing = session.billing; + return !billing || billing.unlimited || Math.max(0, Number(billing.available) || 0) >= estimated; +} + function deriveTurnStatus(turn: ImageTurn): Pick { const loadingCounts = getImageTurnLoadingCounts(turn); const failedCount = turn.images.filter((image) => image.status === "error").length; @@ -812,6 +873,34 @@ function buildCreationTaskMessages(conversation: ImageConversation, activeTurnId return messages; } +function getFallbackReferenceImage(conversation: ImageConversation, activeTurnId: string): FallbackReferenceImage | undefined { + const previousTurns: ImageTurn[] = []; + for (const turn of conversation.turns) { + if (turn.id === activeTurnId) { + break; + } + previousTurns.push(turn); + } + for (let turnIndex = previousTurns.length - 1; turnIndex >= 0; turnIndex -= 1) { + const images = previousTurns[turnIndex].images; + for (let imageIndex = images.length - 1; imageIndex >= 0; imageIndex -= 1) { + const image = images[imageIndex]; + if (image.status !== "success") { + continue; + } + if (image.path || image.url || image.b64_json) { + return { + ...(image.path ? { path: image.path } : {}), + ...(image.url ? { url: image.url } : {}), + ...(image.b64_json ? { b64_json: image.b64_json } : {}), + ...(image.outputFormat ? { outputFormat: image.outputFormat } : {}), + }; + } + } + } + return undefined; +} + async function syncConversationCreationTasks(items: ImageConversation[]) { const taskIds = Array.from( new Set( @@ -988,6 +1077,7 @@ function ImagePageContent({ session }: { session: NonNullable(null); const editFileInputRef = useRef(null); const promptApplyRequestIdRef = useRef(0); + const similarIntentAppliedRef = useRef(false); const [imagePrompt, setImagePrompt] = useState(""); const [composerMode, setComposerMode] = useState(getStoredComposerMode); @@ -1019,6 +1109,11 @@ function ImagePageContent({ session }: { session: NonNullable(null); + const [publishRecipeOptions, setPublishRecipeOptions] = useState({ + sharePromptParameters: false, + shareReferenceImages: false, + }); const canInspectAccounts = session.role === "admin" || session.apiPermissions.includes("get/api/accounts"); const parsedCount = useMemo(() => normalizeRequestedImageCount(imageCount), [imageCount]); @@ -1120,6 +1215,9 @@ function ImagePageContent({ session }: { session: NonNullable { + if (isLoadingHistory || similarIntentAppliedRef.current) { + return; + } + similarIntentAppliedRef.current = true; + + const intent = consumeSimilarImageIntent(); + if (!intent) { + return; + } + + const requestId = promptApplyRequestIdRef.current + 1; + promptApplyRequestIdRef.current = requestId; + const prompt = intent.prompt.trim() || "参考这张图,生成一张风格、主体和构图相近的新图片。"; + const sizeSelection = getImageSizeSelectionFromSize(intent.requestedSize || intent.resolutionPreset || ""); + const outputFormat = isImageOutputFormat(intent.outputFormat) ? intent.outputFormat : DEFAULT_IMAGE_OUTPUT_FORMAT; + + setSelectedConversationId(null); + setComposerMode("image"); + setImagePrompt(prompt); + setImageCount("1"); + setImageModel(isImageCreationModel(intent.model) ? intent.model : DEFAULT_IMAGE_MODEL); + setImageSizeMode(sizeSelection.mode); + setImageAspectRatio(sizeSelection.aspectRatio); + setImageResolution(isImageResolution(intent.resolutionPreset) ? intent.resolutionPreset : sizeSelection.resolution); + setImageCustomRatio(sizeSelection.customRatio); + setImageCustomWidth(sizeSelection.customWidth); + setImageCustomHeight(sizeSelection.customHeight); + setImageOutputFormat(outputFormat); + setImageOutputCompression(reusableOutputCompressionValue(intent.outputCompression, outputFormat)); + setDefaultImageVisibility("private"); + setReferenceImages([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + textareaRef.current?.focus(); + + const sourceImageUrls = intent.sourceImageUrls.length > 0 ? intent.sourceImageUrls : [intent.sourceImageUrl]; + const usesPublicImageFallback = intent.sourceKind !== "original_references"; + const toastId = toast.loading( + usesPublicImageFallback + ? "正在读取公开图作为参考图" + : sourceImageUrls.length > 1 + ? "正在读取公开的原始参考图" + : "正在读取公开的原始参考图", + ); + void Promise.allSettled( + sourceImageUrls.map((url, index) => buildReferenceImageFromUrl(url, index, "public-gallery-reference")), + ) + .then((results) => { + if (promptApplyRequestIdRef.current !== requestId) { + return; + } + const loadedReferences = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []); + if (loadedReferences.length === 0) { + toast.error("已带入原始提示词和参数,但参考图读取失败"); + return; + } + setReferenceImages(loadedReferences); + const failedCount = results.length - loadedReferences.length; + toast.success( + failedCount > 0 + ? `已带入原始提示词、${loadedReferences.length} 张参考图和生成参数,${failedCount} 张读取失败` + : usesPublicImageFallback + ? "未公开原始参考图,已使用公开图和可用参数" + : `已带入原始提示词、${loadedReferences.length} 张原始参考图和生成参数`, + ); + }) + .catch(() => { + if (promptApplyRequestIdRef.current !== requestId) { + return; + } + toast.error("已带入原始提示词和参数,但参考图读取失败"); + }) + .finally(() => { + toast.dismiss(toastId); + }); + }, [isLoadingHistory]); + useEffect(() => { if (!selectedConversationId) { return; @@ -1300,19 +1477,13 @@ function ImagePageContent({ session }: { session: NonNullable 0) { - setReferenceImages([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } return; } if (!isImageCreationModel(imageModel)) { setImageModel(DEFAULT_IMAGE_MODEL); } - }, [composerMode, imageModel, referenceImages.length]); + }, [composerMode, imageModel]); useEffect(() => { if (typeof window === "undefined") { @@ -1428,10 +1599,6 @@ function ImagePageContent({ session }: { session: NonNullable [...prev, ...previews]); + setReferenceImages((prev) => [...prev, ...previews]); if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -1688,7 +1854,13 @@ function ImagePageContent({ session }: { session: NonNullable { + async ( + conversationId: string, + turnId: string, + imageIndex: number, + visibility: ImageVisibility, + options: PublishRecipeOptions = { sharePromptParameters: false, shareReferenceImages: false }, + ) => { const targetConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId); const targetTurn = targetConversation?.turns.find((turn) => turn.id === turnId); const targetImage = targetTurn?.images[imageIndex]; @@ -1705,6 +1877,12 @@ function ImagePageContent({ session }: { session: NonNullable { @@ -1749,9 +1927,25 @@ function ImagePageContent({ session }: { session: NonNullable { + if (!publishImageTarget || visibilityMutatingImageKey) { + return; + } + const target = publishImageTarget; + const options = { + sharePromptParameters: publishRecipeOptions.sharePromptParameters, + shareReferenceImages: publishRecipeOptions.sharePromptParameters && publishRecipeOptions.shareReferenceImages, + }; + try { + await handleImageVisibilityChange(target.conversationId, target.turnId, target.imageIndex, "public", options); + } finally { + setPublishImageTarget(null); + } + }, [handleImageVisibilityChange, publishImageTarget, publishRecipeOptions, visibilityMutatingImageKey]); + const openEditTurnDialog = useCallback((conversationId: string, turnId: string) => { const targetConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId); const targetTurn = targetConversation?.turns.find((turn) => turn.id === turnId); @@ -1968,6 +2162,7 @@ function ImagePageContent({ session }: { session: NonNullable>( (groups, image, imageIndex) => { if (image.status !== "loading") { @@ -1986,6 +2181,15 @@ function ImagePageContent({ session }: { session: NonNullable { if (activeTurn.mode === "chat") { + if (activeTurn.referenceImages.length > 0) { + return createChatCompletionTask( + group.taskId, + activeTurn.prompt, + activeTurn.model, + taskMessages, + activeTurn.referenceImages.map((img) => ({ name: img.name, dataUrl: img.dataUrl })), + ); + } return createChatCompletionTask(group.taskId, activeTurn.prompt, activeTurn.model, taskMessages); } if (usesReferenceImages(activeTurn.mode)) { @@ -2002,6 +2206,9 @@ function ImagePageContent({ session }: { session: NonNullable { @@ -2124,7 +2338,7 @@ function ImagePageContent({ session }: { session: NonNullable { for (const conversation of conversations) { @@ -2533,6 +2747,11 @@ function ImagePageContent({ session }: { session: NonNullable + {publishImageTarget ? ( + (!open && !visibilityMutatingImageKey ? setPublishImageTarget(null) : null)}> + + + 公开图片 + + 将这张图片加入公开图库。 + + +
+ + +
+ + + + +
+
+ ) : null} + {deleteConfirm ? ( (!open ? setDeleteConfirm(null) : null)}> diff --git a/web/src/app/image/similar-image-intent.ts b/web/src/app/image/similar-image-intent.ts new file mode 100644 index 000000000..29edf41d5 --- /dev/null +++ b/web/src/app/image/similar-image-intent.ts @@ -0,0 +1,88 @@ +export const SIMILAR_IMAGE_INTENT_STORAGE_KEY = "chatgpt2api:image_similar_intent"; + +export type SimilarImageIntent = { + id: string; + createdAt: string; + prompt: string; + sourceImageUrl: string; + sourceImageUrls: string[]; + sourceKind?: "original_references" | "public_image"; + sourceImageName?: string; + model?: string; + quality?: string; + requestedSize?: string; + resolutionPreset?: string; + outputFormat?: string; + outputCompression?: number; +}; + +type SimilarImageIntentInput = Omit & { + sourceImageUrl?: string; + sourceImageUrls?: string[]; +}; + +function normalizeSourceImageUrls(sourceImageUrls?: string[], sourceImageUrl?: string) { + return Array.from( + new Set( + [...(sourceImageUrls || []), sourceImageUrl || ""] + .map((url) => url.trim()) + .filter(Boolean), + ), + ); +} + +export function writeSimilarImageIntent(intent: SimilarImageIntentInput) { + const sourceImageUrls = normalizeSourceImageUrls(intent.sourceImageUrls, intent.sourceImageUrl); + if (sourceImageUrls.length === 0) { + return; + } + window.localStorage.setItem( + SIMILAR_IMAGE_INTENT_STORAGE_KEY, + JSON.stringify({ + ...intent, + sourceImageUrl: sourceImageUrls[0], + sourceImageUrls, + sourceKind: intent.sourceKind === "original_references" ? "original_references" : "public_image", + id: typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`, + createdAt: new Date().toISOString(), + }), + ); +} + +export function consumeSimilarImageIntent(): SimilarImageIntent | null { + const raw = window.localStorage.getItem(SIMILAR_IMAGE_INTENT_STORAGE_KEY); + if (!raw) { + return null; + } + window.localStorage.removeItem(SIMILAR_IMAGE_INTENT_STORAGE_KEY); + + try { + const parsed = JSON.parse(raw) as Partial; + const sourceImageUrls = normalizeSourceImageUrls( + Array.isArray(parsed.sourceImageUrls) ? parsed.sourceImageUrls.filter((url): url is string => typeof url === "string") : [], + typeof parsed.sourceImageUrl === "string" ? parsed.sourceImageUrl : "", + ); + if (sourceImageUrls.length === 0) { + return null; + } + return { + id: typeof parsed.id === "string" ? parsed.id : "", + createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "", + prompt: typeof parsed.prompt === "string" ? parsed.prompt : "", + sourceImageUrl: sourceImageUrls[0], + sourceImageUrls, + sourceKind: parsed.sourceKind === "original_references" ? "original_references" : "public_image", + sourceImageName: typeof parsed.sourceImageName === "string" ? parsed.sourceImageName : undefined, + model: typeof parsed.model === "string" ? parsed.model : undefined, + quality: typeof parsed.quality === "string" ? parsed.quality : undefined, + requestedSize: typeof parsed.requestedSize === "string" ? parsed.requestedSize : undefined, + resolutionPreset: typeof parsed.resolutionPreset === "string" ? parsed.resolutionPreset : undefined, + outputFormat: typeof parsed.outputFormat === "string" ? parsed.outputFormat : undefined, + outputCompression: typeof parsed.outputCompression === "number" ? parsed.outputCompression : undefined, + }; + } catch { + return null; + } +} diff --git a/web/src/app/logs/page.tsx b/web/src/app/logs/page.tsx index 1074adf64..e2dce6bbe 100644 --- a/web/src/app/logs/page.tsx +++ b/web/src/app/logs/page.tsx @@ -15,25 +15,40 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { fetchSystemLogs, type SystemLog, type SystemLogFilters } from "@/lib/api"; +import { fetchSettingsConfig, fetchSystemLogs, type LogView, type SystemLog, type SystemLogFilters } from "@/lib/api"; import { useAuthGuard } from "@/lib/use-auth-guard"; const methodOptions = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const statusOptions = ["200", "201", "400", "401", "403", "404", "422", "429", "500", "502"]; const logLevelOptions = ["info", "warning", "error"]; +const logViewOptions: Array<{ value: LogView; label: string }> = [ + { value: "meaningful", label: "有意义日志" }, + { value: "business", label: "仅业务日志" }, + { value: "all", label: "全部日志" }, +]; -const emptyFilters: SystemLogFilters = { - username: "", - module: "", - summary: "", - method: "all", - status: "all", - ip_address: "", - operation_type: "", - log_level: "all", - start_date: "", - end_date: "", -}; +function normalizeLogView(value: unknown): LogView { + if (value === "all" || value === "meaningful" || value === "business") { + return value; + } + return "meaningful"; +} + +function createEmptyFilters(view: LogView): SystemLogFilters { + return { + username: "", + module: "", + summary: "", + method: "all", + status: "all", + ip_address: "", + operation_type: "", + log_level: "all", + view, + start_date: "", + end_date: "", + }; +} const detailLabels: Record = { endpoint: "接口", @@ -51,6 +66,8 @@ const detailLabels: Record = { ended_at: "结束时间", username: "操作人", key_name: "令牌名称", + session_name: "会话名称", + auth_kind: "认证方式", key_role: "角色", key_id: "凭据 ID", subject_id: "用户 ID", @@ -65,24 +82,40 @@ const detailLabels: Record = { removed: "删除", }; -const primaryDetailKeys = [ +const summaryDetailKeys = new Set([ "method", "path", "endpoint", "module", - "operation_type", "status", "outcome", "log_level", "duration_ms", - "username", - "key_name", - "subject_id", - "key_id", - "ip_address", - "started_at", - "ended_at", -]; + "response_time", +]); + +const detailSectionDefinitions = [ + { + title: "请求", + keys: ["operation_type", "ip_address", "user_agent", "model"], + }, + { + title: "身份", + keys: ["username", "key_name", "session_name", "auth_kind", "key_role", "subject_id", "key_id", "provider"], + }, + { + title: "时间", + keys: ["started_at", "ended_at"], + }, +] as const; + +type DetailFieldSection = { + title: string; + entries: Array; +}; + +const groupedDetailKeys = new Set(detailSectionDefinitions.flatMap((section) => section.keys)); +const payloadDetailKeys = new Set(["request_args", "request_body", "response_body"]); function primitiveText(value: unknown) { return typeof value === "string" || typeof value === "number" ? String(value) : ""; @@ -97,7 +130,7 @@ function detailText(item: SystemLog | null, key: string) { } function actorText(item: SystemLog | null) { - return detailText(item, "username") || detailText(item, "key_name") || detailText(item, "subject_id") || detailText(item, "key_id") || "-"; + return detailText(item, "username") || detailText(item, "key_name") || detailText(item, "subject_id") || detailText(item, "key_id") || detailText(item, "session_name") || "-"; } function moduleText(item: SystemLog | null) { @@ -172,6 +205,10 @@ function isPrimitiveDetail(value: unknown) { return value === null || ["string", "number", "boolean"].includes(typeof value); } +function isDisplayableDetailValue(value: unknown) { + return isPrimitiveDetail(value) && value !== null && value !== undefined && value !== ""; +} + function formatDetailValue(key: string, value: unknown) { if (value === null || value === undefined || value === "") return "—"; if ((key === "duration_ms" || key === "response_time") && typeof value === "number") return `${(value / 1000).toFixed(2)} s`; @@ -179,21 +216,53 @@ function formatDetailValue(key: string, value: unknown) { if (value === "success") return "成功"; if (value === "failed") return "失败"; } + if (key === "auth_kind") { + if (value === "session") return "登录会话"; + if (value === "api_key") return "API 令牌"; + } if (typeof value === "boolean") return value ? "是" : "否"; return String(value); } -function getPrimaryDetailEntries(item: SystemLog | null) { +function isRedundantDetailEntry(item: SystemLog | null, key: string, value: unknown) { + if (summaryDetailKeys.has(key)) { + return true; + } + const text = primitiveText(value); + if (key === "session_name" && detailText(item, "auth_kind") === "session" && text === "登录会话") { + return true; + } + if (text && ["username", "key_name", "session_name", "subject_id", "key_id"].includes(key)) { + return actorText(item) === text; + } + return false; +} + +function getDetailGroupEntries(item: SystemLog | null, keys: readonly string[]) { const detail = item?.detail || {}; - return primaryDetailKeys - .filter((key) => key in detail && isPrimitiveDetail(detail[key])) + return keys + .filter((key) => key in detail && isDisplayableDetailValue(detail[key]) && !isRedundantDetailEntry(item, key, detail[key])) .map((key) => [key, detail[key]] as const); } function getExtraDetailEntries(item: SystemLog | null) { const detail = item?.detail || {}; - const skipped = new Set([...primaryDetailKeys, "urls", "error"]); - return Object.entries(detail).filter(([key, value]) => !skipped.has(key) && isPrimitiveDetail(value)); + const skipped = new Set([...summaryDetailKeys, ...groupedDetailKeys, ...payloadDetailKeys, "urls", "error"]); + return Object.entries(detail).filter(([key, value]) => !skipped.has(key) && isDisplayableDetailValue(value)); +} + +function getDetailFieldSections(item: SystemLog | null) { + const sections: DetailFieldSection[] = detailSectionDefinitions + .map((section) => ({ + title: section.title, + entries: getDetailGroupEntries(item, section.keys), + })) + .filter((section) => section.entries.length > 0); + const extraEntries = getExtraDetailEntries(item); + if (extraEntries.length > 0) { + sections.push({ title: "其他", entries: extraEntries }); + } + return sections; } function detailJSON(item: SystemLog | null) { @@ -210,15 +279,19 @@ function normalizeFilters(filters: SystemLogFilters): SystemLogFilters { ip_address: filters.ip_address?.trim() || "", operation_type: filters.operation_type?.trim() || "", log_level: filters.log_level || "all", + view: normalizeLogView(filters.view), start_date: filters.start_date || "", end_date: filters.end_date || "", }; } function LogsContent() { + const initialFilters = createEmptyFilters("meaningful"); const [items, setItems] = useState([]); - const [filters, setFilters] = useState(emptyFilters); - const [query, setQuery] = useState(emptyFilters); + const [defaultLogView, setDefaultLogView] = useState("meaningful"); + const [isDefaultLogViewReady, setIsDefaultLogViewReady] = useState(false); + const [filters, setFilters] = useState(initialFilters); + const [query, setQuery] = useState(initialFilters); const [detailLog, setDetailLog] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); @@ -227,6 +300,8 @@ function LogsContent() { const [isLoading, setIsLoading] = useState(true); const detailUrls = getUrls(detailLog); const detailImages = detailUrls.map((url, index) => ({ id: `${index}`, src: url })); + const detailMethod = detailText(detailLog, "method"); + const detailFieldSections = getDetailFieldSections(detailLog); const pageSize = 15; const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); const safePage = Math.min(page, pageCount); @@ -255,8 +330,9 @@ function LogsContent() { }; const clearFilters = () => { - setFilters(emptyFilters); - setQuery(emptyFilters); + const nextFilters = createEmptyFilters(defaultLogView); + setFilters(nextFilters); + setQuery(nextFilters); }; const openDetail = (item: SystemLog) => { @@ -274,8 +350,36 @@ function LogsContent() { }; useEffect(() => { + let ignore = false; + const loadDefaultLogView = async () => { + let view: LogView = "meaningful"; + try { + const data = await fetchSettingsConfig(); + view = normalizeLogView(data.config.default_log_view); + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载默认日志视图失败"); + } + if (ignore) { + return; + } + const nextFilters = createEmptyFilters(view); + setDefaultLogView(view); + setFilters(nextFilters); + setQuery(nextFilters); + setIsDefaultLogViewReady(true); + }; + void loadDefaultLogView(); + return () => { + ignore = true; + }; + }, []); + + useEffect(() => { + if (!isDefaultLogViewReady) { + return; + } void loadLogs(query); - }, [loadLogs, query]); + }, [isDefaultLogViewReady, loadLogs, query]); return (
@@ -287,6 +391,12 @@ function LogsContent() {
+ updateFilter("username", event.target.value)} /> updateFilter("module", event.target.value)} /> updateFilter("summary", event.target.value)} /> @@ -443,7 +553,10 @@ function LogsContent() {
接口
-
{pathText(detailLog)}
+
+ {detailMethod ? {detailMethod} : null} + {pathText(detailLog)} +
耗时
@@ -453,22 +566,26 @@ function LogsContent() {
-
-
关键字段
-
- {[...getPrimaryDetailEntries(detailLog), ...getExtraDetailEntries(detailLog)].map(([key, value]) => ( -
- {detailLabel(key)} - {formatDetailValue(key, value)} -
- ))} - {getPrimaryDetailEntries(detailLog).length === 0 && getExtraDetailEntries(detailLog).length === 0 ? ( -
- 没有可展示的字段 -
- ) : null} -
-
+ {detailFieldSections.length > 0 ? ( +
+
补充信息
+
+ {detailFieldSections.map((section) => ( +
+
{section.title}
+
+ {section.entries.map(([key, value]) => ( +
+ {detailLabel(key)} + {formatDetailValue(key, value)} +
+ ))} +
+
+ ))} +
+
+ ) : null} {typeof detailLog?.detail?.error === "string" && detailLog.detail.error ? (
@@ -505,13 +622,6 @@ function LogsContent() {
) : null} - -
-
完整 JSON
-
-                  {detailJSON(detailLog)}
-                
-
diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index d5c1aa3c7..b7c982f3f 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -5,6 +5,7 @@ import { Ban, CheckCircle2, Copy, + Gauge, Eye, EyeOff, KeyRound, @@ -94,6 +95,37 @@ function creationConcurrentLimitLabel(session: StoredAuthSession) { return `${session.creationConcurrentLimit} 个`; } +function creationRpmLimitLabel(session: StoredAuthSession) { + if (session.role === "admin" || session.creationRpmLimit === 0) { + return "不限制"; + } + return `${session.creationRpmLimit} 次/分`; +} + +function billingTypeLabel(session: StoredAuthSession) { + const billing = session.billing; + if (!billing || billing.unlimited) { + return "无限额度"; + } + return billing.type === "subscription" ? "订阅配额制" : "标准余额制"; +} + +function billingPrimaryValue(session: StoredAuthSession) { + const billing = session.billing; + if (!billing || billing.unlimited) { + return "不限制"; + } + if (billing.type === "subscription") { + return `${billing.available} / ${billing.subscription?.quota_limit ?? 0}`; + } + return String(billing.standard?.available_balance ?? billing.available); +} + +function billingResetLabel(session: StoredAuthSession) { + const endsAt = session.billing?.subscription?.quota_period_ends_at; + return endsAt ? formatDateTime(endsAt) : "—"; +} + function maskKey(hasKey: boolean) { return hasKey ? "sk-••••••••••••••••••••••••••••••••" : "未生成"; } @@ -358,6 +390,36 @@ function ProfileContent({ session }: { session: StoredAuthSession }) { + + + + + + +
+
+ +
+
+ 本地计费 + 图片计费单位 +
+
+
+ + + + {currentSession.billing?.type === "subscription" && !currentSession.billing.unlimited ? ( + <> + + + + ) : null} + {currentSession.billing?.type === "standard" && !currentSession.billing.unlimited ? ( + <> + + + ) : null}
diff --git a/web/src/app/rbac/page.tsx b/web/src/app/rbac/page.tsx index 09b5fcd7e..f9795f0fe 100644 --- a/web/src/app/rbac/page.tsx +++ b/web/src/app/rbac/page.tsx @@ -246,8 +246,8 @@ function RBACContent() { />
- - + +
角色 {filteredRoles.length} / {roles.length} @@ -366,7 +366,7 @@ function RBACContent() { />
-
+
{isLoading ? (
diff --git a/web/src/app/register/components/register-card.tsx b/web/src/app/register/components/register-card.tsx index aefb3ab34..dbf616bc5 100644 --- a/web/src/app/register/components/register-card.tsx +++ b/web/src/app/register/components/register-card.tsx @@ -52,6 +52,8 @@ export function RegisterCard() { ...(type === "duckmail" ? { api_key: "", default_domain: "duckmail.sbs" } : {}), ...(type === "gptmail" ? { api_key: "", default_domain: "" } : {}), ...(type === "moemail" ? { api_base: "", api_key: "", domain: [], expiry_time: 0 } : {}), + ...(type === "inbucket" ? { api_base: "", domain: [], random_subdomain: true } : {}), + ...(type === "yyds_mail" ? { api_base: "https://maliapi.215.im/v1", api_key: "", domain: [], subdomain: "", wildcard: false } : {}), }); }; @@ -144,7 +146,12 @@ export function RegisterCard() { {providers.map((provider, index) => { const type = String(provider.type || "tempmail_lol"); const domains = Array.isArray(provider.domain) ? provider.domain.map(String).join("\n") : ""; - const domainPlaceholder = type === "tempmail_lol" ? "每行一个域名,留空则使用服务默认域名" : "每行一个域名"; + const domainPlaceholder = + type === "inbucket" + ? "每行一个基础域名,系统会自动生成随机子域名" + : type === "tempmail_lol" + ? "每行一个域名,留空则使用服务默认域名" + : "每行一个域名"; return (
@@ -170,10 +177,12 @@ export function RegisterCard() { duckmail gptmail(未测试) moemail + inbucket + yyds_mail
- {type === "cloudflare_temp_email" || type === "moemail" ? ( + {type === "cloudflare_temp_email" || type === "moemail" || type === "inbucket" || type === "yyds_mail" ? (
updateProvider(index, { api_base: event.target.value })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} /> @@ -187,7 +196,13 @@ export function RegisterCard() {
) : null} - {type === "tempmail_lol" || type === "duckmail" || type === "gptmail" || type === "moemail" ? ( + {type === "inbucket" ? ( + + ) : null} + {type === "tempmail_lol" || type === "duckmail" || type === "gptmail" || type === "moemail" || type === "yyds_mail" ? (
updateProvider(index, { api_key: event.target.value })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} /> @@ -205,11 +220,23 @@ export function RegisterCard() { updateProvider(index, { expiry_time: Number(event.target.value) || 0 })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} />
) : null} + {type === "yyds_mail" ? ( + <> +
+ + updateProvider(index, { subdomain: event.target.value })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} /> +
+ + + ) : null}
- {type === "tempmail_lol" || type === "cloudflare_temp_email" || type === "moemail" ? ( + {type === "tempmail_lol" || type === "cloudflare_temp_email" || type === "moemail" || type === "inbucket" || type === "yyds_mail" ? (
- +