diff --git a/.dockerignore b/.dockerignore index d5369692cb..c6167a25b2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -49,6 +49,9 @@ coverage/ .env.* !.env.example +# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。 +/plugins/ + # Local config config.yaml config.local.yaml diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml index 2a89dd0761..6243f0fa7f 100644 --- a/.github/audit-exceptions.yml +++ b/.github/audit-exceptions.yml @@ -35,3 +35,10 @@ exceptions: mitigation: "Proxy configuration not user-controlled; upgrade when axios releases fix" expires_on: "2026-07-10" owner: "security@your-domain" + - package: nanoid + advisory: "GHSA-2v37-7h3g-55p8" + severity: high + reason: "Custom generator with size=0 not used; all nanoid calls use default size or positive values" + mitigation: "No zero-size custom generators in codebase; upgrade when nanoid releases fix" + expires_on: "2026-10-13" + owner: "security@your-domain" diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 21a4fea79c..2bac7259a9 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,6 +17,7 @@ jobs: /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh /bin/sh deploy/tests/docker-compose-security-test.sh + /bin/sh deploy/tests/docker-compose-gateway-env-test.sh /bin/sh deploy/tests/docker-runtime-resources-test.sh /bin/sh deploy/test-caddyfile-cache.sh @@ -32,7 +33,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.5' + go version | grep -q 'go1.27.0' - name: Unit tests working-directory: backend run: make test-unit @@ -72,10 +73,10 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.5' + go version | grep -q 'go1.27.0' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.9 + version: v2.13 args: --timeout=30m working-directory: backend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ba01833c1..abdfc9c81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.5' + go version | grep -q 'go1.27.0' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 96a7ae4edd..97357efde2 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.5' + go version | grep -q 'go1.27.0' - name: Run govulncheck working-directory: backend run: | diff --git a/.gitignore b/.gitignore index 4ea6ff311b..20ed12275e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -docs/claude-relay-service/ +# 本地杂项文档、测试数据和外部项目副本 +/docs-local/ .codex # =================== @@ -136,13 +137,23 @@ docs/* !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md !docs/ASYNC_IMAGE_TASKS.md +!docs/BATCH_IMAGE_MVP.md +!docs/COMPOSITE_GROUPS.md +!docs/PLUGIN_DEVELOPMENT.md +!docs/channel-monitor-v2-safe-defaults.md !docs/legal/ !docs/legal/*.md +!docs/screenshots/ +docs/screenshots/* +!docs/screenshots/mobile-account-actions-menu.png .serena/ .codex/ frontend/coverage/ aicodex output/ +.codegraph/ # Vitest / Vite cache at repo root .vite/ +# 本地闭源插件开发目录及构建产物不进入 Sub2API 仓库 +/plugins/ diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index 349c244491..2f440d0f62 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -34,8 +34,8 @@ ### 开发工具 ```bash -# golangci-lint(CI 用 v2.9,本地建议装同一版以免版本差异带来的噪音) -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9 +# golangci-lint(CI 用 v2.13,本地建议装同一版以免版本差异带来的噪音) +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13 # pnpm (前端包管理) npm install -g pnpm @@ -47,13 +47,13 @@ npm install -g pnpm | Workflow | 触发条件 | 检查内容 | |----------|----------|----------| -| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.9 | +| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.13 | | **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit | | **release.yml** | tag `v*` | 构建发布(PR 不触发) | ### CI 要求 -- Go 版本必须是 **1.26.5**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.5'`。升级 Go 时要同时改 `backend/go.mod` 和 `backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,否则 CI 会在版本校验步骤直接失败。 +- Go 版本必须是 **1.27.0**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.0'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 - 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml` ### 本地测试命令 @@ -203,7 +203,7 @@ go test -tags=integration ./... **解决**: ```bash cd backend -go generate ./ent # 重新生成 ent 代码 +go generate ./ent # 重新生成 ent 代码(json.RawMessage 字段会生成为同类型的 jsontext.Value,属预期) git add ent/ # 生成的文件也要提交 ``` diff --git a/Dockerfile b/Dockerfile index b2dfd7034f..adfa4d4702 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.5-alpine +ARG GOLANG_IMAGE=golang:1.27.0-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/README.md b/README.md index f4ad2fa6b0..5d0c4f433a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -58,6 +58,11 @@ Please read the following carefully before using this project: Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! + +CodexEverywhere +Real GPT-5.6 series at 3% of OpenAI pricing — CodexEverywhere is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at codex-everywhere.com. + + bmoplus Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through BmoPlus - Premium AI Accounts & Top-ups, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF) @@ -117,18 +122,6 @@ Please read the following carefully before using this project: - -claudeapi -Thanks to Claude API for sponsoring this project! Claude API is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the exclusive link and contact customer support to receive free trial credits; invoicing and team onboarding are also supported. - - - - -code0 -Thanks to code0.ai for sponsoring this project! code0.ai is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the exclusive link and contact customer support to receive free trial credits and experience a more efficient AI coding workflow. - - - Nagora Nagora is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute. @@ -174,6 +167,11 @@ Please read the following carefully before using this project: DuckIP - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial. + +APIMart +Thanks to APIMart for sponsoring this project! APIMart is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — sign up here to get started. + + ## Overview @@ -206,7 +204,7 @@ Community projects that extend or integrate with Sub2API: | Component | Technology | |-----------|------------| -| Backend | Go 1.26.5, Gin, Ent | +| Backend | Go 1.27.0, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | @@ -550,12 +548,6 @@ default: rate_multiplier: 1.0 ``` -### Sora Status (Temporarily Unavailable) - -> ⚠️ Sora-related features are temporarily unavailable due to technical issues in upstream integration and media delivery. -> Please do not rely on Sora in production at this time. -> Existing `gateway.sora_*` configuration keys are reserved and may not take effect until these issues are resolved. - Additional security-related options are available in `config.yaml`: - `cors.allowed_origins` for CORS allowlist @@ -658,6 +650,32 @@ Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment. Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out or mitigating upstream WebSocket issues. +#### Force OpenAI upstream HTTP/SSE + +When an egress proxy or network repeatedly reconnects OpenAI Responses +WebSockets, set the global fallback in the persisted deployment configuration: + +```yaml +gateway: + openai_ws: + force_http: true +``` + +For Compose and Apple container deployments, the equivalent `.env` setting is: + +```bash +GATEWAY_OPENAI_WS_FORCE_HTTP=true +``` + +This selects HTTP/SSE for OpenAI upstream Responses traffic that would +otherwise use WebSocket. It does not change the client-facing protocol or force +HTTP/1.1; configure `gateway.openai_http2.enabled` (or +`GATEWAY_OPENAI_HTTP2_ENABLED=false`) separately when a proxy is incompatible +with HTTP/2. Unlike the account-level `http_bridge` mode, this global fallback +takes effect without enabling `mode_router_v2_enabled`. Keep the setting in the +deployment's persisted `.env` or `config.yaml`, rather than inside a running +container, so it is read again after an image update or container recreation. + #### ⚠️ Important: Creating the Admin Account The initial admin account is **only created via the setup wizard** (served at `http://:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons. @@ -736,7 +754,7 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard - Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream - Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309` - Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission. -- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5` +- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-image-2.0`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5` - JSON image-edit and video-generation requests accept image references in `image`, `images`, `reference_images`, and `mask` objects. Use `url` for xAI-compatible payloads; the legacy `image_url` field remains accepted and is normalized to `url` before forwarding. - Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping @@ -866,11 +884,11 @@ sub2api/ ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README_CN.md b/README_CN.md index 2d2a57a9ff..faa0200c58 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -59,6 +59,11 @@ 感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过此链接注册,首次充值可额外获得 10% 赠送额度! + +CodexEverywhere +Real GPT-5.6 series at 3% of OpenAI pricing — CodexEverywhere is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at codex-everywhere.com. + + bmoplus 感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! @@ -120,18 +125,6 @@ - -claudeapi -感谢 Claude API 对本项目的赞助! Claude API 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过[专属链接]注册后联系客服,可领取免费测试额度,并支持开票和团队对接。 - - - - -code0 -感谢 code0.ai 对本项目的赞助! code0.ai 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过[专属链接]注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。 - - - Nagora Nagora 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。 @@ -177,6 +170,11 @@ DuckIP - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。 + +APIMart +感谢 APIMart 赞助了本项目!APIMart 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过此注册链接注册即可开始使用。 + + ## 项目概述 @@ -208,7 +206,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 组件 | 技术 | |------|------| -| 后端 | Go 1.26.5, Gin, Ent | +| 后端 | Go 1.27.0, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | @@ -563,33 +561,6 @@ default: rate_multiplier: 1.0 ``` -### Sora 功能状态(暂不可用) - -> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。 -> 现阶段请勿在生产环境依赖 Sora 能力。 -> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。 - -### Sora 媒体签名 URL(功能恢复后可选) - -当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。 - -```yaml -gateway: - # /sora/media 是否强制要求 API Key(默认 false) - sora_media_require_api_key: false - # 媒体临时签名密钥(为空则禁用签名) - sora_media_signing_key: "your-signing-key" - # 临时签名 URL 有效期(秒) - sora_media_signed_url_ttl_seconds: 900 -``` - -> 若未配置签名密钥,`/sora/media-signed` 将返回 503。 -> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。 - -访问策略说明: -- `/sora/media`:内部调用或客户端携带 API Key 才能下载 -- `/sora/media-signed`:外部可访问,但有签名 + 过期控制 - `config.yaml` 还支持以下安全相关配置: - `cors.allowed_origins` 配置 CORS 白名单 @@ -797,11 +768,11 @@ sub2api/ ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README_JA.md b/README_JA.md index 09aa77ee48..9bd3802a32 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -58,6 +58,11 @@ AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:こちらのリンクから登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント! + +CodexEverywhere +OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — CodexEverywhere は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。codex-everywhere.com で $20 の無料トライアルから始めましょう。 + + bmoplus 本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます! @@ -119,18 +124,6 @@ - -claudeapi -Claude API のご支援に感謝します!Claude API は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。 - - - - -code0 -code0.ai のご支援に感謝します!code0.ai は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。 - - - Nagora Nagoraは、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。 @@ -176,6 +169,11 @@ DuckIP - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。 + +APIMart +APIMart のご支援に感謝します!APIMart は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、こちらの登録リンクからすぐに利用を開始できます。 + + ## 概要 @@ -207,7 +205,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | コンポーネント | 技術 | |-----------|------------| -| バックエンド | Go 1.26.5, Gin, Ent | +| バックエンド | Go 1.27.0, Gin, Ent | | フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS | | データベース | PostgreSQL 15+ | | キャッシュ/キュー | Redis 7+ | @@ -550,12 +548,6 @@ default: rate_multiplier: 1.0 ``` -### Sora ステータス(一時的に利用不可) - -> ⚠️ Sora 関連の機能は、上流統合およびメディア配信の技術的問題により一時的に利用できません。 -> 現時点では本番環境で Sora に依存しないでください。 -> 既存の `gateway.sora_*` 設定キーは予約されていますが、これらの問題が解決されるまで有効にならない場合があります。 - `config.yaml` では追加のセキュリティ関連オプションも利用できます: - `cors.allowed_origins` - CORS 許可リスト @@ -732,11 +724,11 @@ sub2api/ ## スター履歴 - + - - - Star History Chart + + + Star History Chart diff --git a/assets/partners/logos/apimart.jpg b/assets/partners/logos/apimart.jpg new file mode 100644 index 0000000000..e670cea236 Binary files /dev/null and b/assets/partners/logos/apimart.jpg differ diff --git a/assets/partners/logos/claudeapi.jpg b/assets/partners/logos/claudeapi.jpg deleted file mode 100644 index 026c0f0390..0000000000 Binary files a/assets/partners/logos/claudeapi.jpg and /dev/null differ diff --git a/assets/partners/logos/code0.jpg b/assets/partners/logos/code0.jpg deleted file mode 100644 index aa179d747d..0000000000 Binary files a/assets/partners/logos/code0.jpg and /dev/null differ diff --git a/assets/partners/logos/codex-everywhere.jpg b/assets/partners/logos/codex-everywhere.jpg new file mode 100644 index 0000000000..a89679ada5 Binary files /dev/null and b/assets/partners/logos/codex-everywhere.jpg differ diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 92ba391694..08d1b61f45 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -121,6 +121,16 @@ linters: # Default: true — must be true, ent generates 130K+ lines of code generated-is-used: true + exclusions: + rules: + # G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的 + # 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的 + # 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免, + # 必须逐点 //nolint:gosec 并写明信任边界。 + - path: '_test\.go$' + linters: [ gosec ] + text: 'G70[34]' + formatters: enable: - gofmt diff --git a/backend/Dockerfile b/backend/Dockerfile index 9976abe46b..398d9bdfcf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.5-alpine +FROM golang:1.27.0-alpine WORKDIR /app diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index e8be64cc0f..24f1cf39c7 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.176 +0.1.183 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a6fe5d42c3..8494713ba1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -153,6 +153,11 @@ func runMainServer() { log.Fatalf("Failed to initialize application: %v", err) } defer app.Cleanup() + if app.PluginManager != nil { + if err := app.PluginManager.Start(context.Background()); err != nil { + log.Printf("Plugin manager started in degraded state: %v", err) + } + } if app.PromptAudit != nil { if err := app.PromptAudit.Start(context.Background()); err != nil { // Startup continues so unrelated APIs stay up. Fail-closed (unavailable) diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 8bd9ac5b16..06720610ba 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -25,9 +25,10 @@ import ( ) type Application struct { - Server *http.Server - PromptAudit *securityaudit.PromptService - Cleanup func() + Server *http.Server + PromptAudit *securityaudit.PromptService + PluginManager *service.PluginManager + Cleanup func() } func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { @@ -51,12 +52,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { // BuildInfo provider provideServiceBuildInfo, + providePluginHostInfo, // Cleanup function provider provideCleanup, // Application struct - wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"), + wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"), ) return nil, nil } @@ -72,6 +74,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo { } } +func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo { + return service.PluginHostInfo{ + Version: buildInfo.Version, + BuildType: buildInfo.BuildType, + } +} + func provideCleanup( entClient *ent.Client, rdb *redis.Client, @@ -88,6 +97,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, @@ -115,7 +125,9 @@ func provideCleanup( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, auditLog *service.AuditLogService, + openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, + pluginManager *service.PluginManager, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -128,6 +140,18 @@ func provideCleanup( // 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。 parallelSteps := []cleanupStep{ + {"PluginManager", func() error { + if pluginManager != nil { + pluginManager.Stop() + } + return nil + }}, + {"OpenAIQuotaAutoResetService", func() error { + if openAIAutoReset != nil { + openAIAutoReset.Stop() + } + return nil + }}, {"OpsIngressRejectAggregator", func() error { if opsIngressReject != nil { opsIngressReject.Stop() @@ -238,6 +262,12 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"CNProviderBalanceCheckService", func() error { + if cnProviderBalanceCheck != nil { + cnProviderBalanceCheck.Stop() + } + return nil + }}, {"OpenAICodexVersionSyncService", func() error { codexVersionSync.Stop() return nil @@ -321,12 +351,12 @@ func provideCleanup( return nil }}, {"ChannelMonitorV2Aggregator", func() error { - if channelMonitorV2Aggregator != nil { - channelMonitorV2Aggregator.Stop() - } - return nil - }}, - {"ChannelMonitorRunner", func() error { + if channelMonitorV2Aggregator != nil { + channelMonitorV2Aggregator.Stop() + } + return nil + }}, + {"ChannelMonitorRunner", func() error { if channelMonitorRunner != nil { channelMonitorRunner.Stop() } diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ab9f4530b8..b2774d7232 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -194,13 +194,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) - antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) + antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService) openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService) usageCache := service.NewUsageCache() accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService) - accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService) + pluginRepository := repository.NewPluginRepository(db) + pluginHostInfo := providePluginHostInfo(buildInfo) + pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo) + accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) @@ -218,6 +221,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService) grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService, tokenRefreshService) + cnProviderQuotaService := service.ProvideCNProviderQuotaService(accountRepository, proxyRepository, httpUpstream, configConfig) + cnProviderBalanceService := service.ProvideCNProviderBalanceService(accountRepository, proxyRepository, httpUpstream, configConfig) + cnProviderHandler := admin.NewCNProviderHandler(cnProviderQuotaService, cnProviderBalanceService) proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService) promoHandler := admin.NewPromoHandler(promoService) @@ -248,6 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache) errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService) tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService) + pluginHandler := admin.NewPluginHandler(pluginManager) adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService) scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db) scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db) @@ -277,14 +284,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) @@ -297,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) - modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService) + modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver) + modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService) imageTaskStore := repository.NewImageTaskStore(redisClient) imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService) asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler) @@ -311,7 +319,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) @@ -327,20 +336,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) + cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig) openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient) proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db) batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) - channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) + channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository, configConfig) + channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) application := &Application{ - Server: httpServer, - PromptAudit: promptService, - Cleanup: v, + Server: httpServer, + PromptAudit: promptService, + PluginManager: pluginManager, + Cleanup: v, } return application, nil } @@ -348,9 +360,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { // wire.go: type Application struct { - Server *http.Server - PromptAudit *securityaudit.PromptService - Cleanup func() + Server *http.Server + PromptAudit *securityaudit.PromptService + PluginManager *service.PluginManager + Cleanup func() } func providePrivacyClientFactory() service.PrivacyClientFactory { @@ -364,6 +377,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo { } } +func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo { + return service.PluginHostInfo{ + Version: buildInfo.Version, + BuildType: buildInfo.BuildType, + } +} + func provideCleanup( entClient *ent.Client, rdb *redis.Client, @@ -380,6 +400,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, @@ -407,7 +428,9 @@ func provideCleanup( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, auditLog *service.AuditLogService, + openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, + pluginManager *service.PluginManager, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -419,6 +442,18 @@ func provideCleanup( } parallelSteps := []cleanupStep{ + {"PluginManager", func() error { + if pluginManager != nil { + pluginManager.Stop() + } + return nil + }}, + {"OpenAIQuotaAutoResetService", func() error { + if openAIAutoReset != nil { + openAIAutoReset.Stop() + } + return nil + }}, {"OpsIngressRejectAggregator", func() error { if opsIngressReject != nil { opsIngressReject.Stop() @@ -529,6 +564,12 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"CNProviderBalanceCheckService", func() error { + if cnProviderBalanceCheck != nil { + cnProviderBalanceCheck.Stop() + } + return nil + }}, {"OpenAICodexVersionSyncService", func() error { codexVersionSync.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index dff3a9129e..b3581641ea 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -66,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { schedulerSnapshotSvc, tokenRefreshSvc, accountExpirySvc, + nil, // cnProviderBalanceCheck codexVersionSyncSvc, proxyExpirySvc, subscriptionExpirySvc, @@ -93,7 +94,9 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // upstreamBillingProbe nil, // ollamaCloudUsage nil, // auditLog + nil, // openAIAutoReset nil, // promptAudit + nil, // pluginManager ) require.NotPanics(t, func() { diff --git a/backend/ent/channelmonitor.go b/backend/ent/channelmonitor.go index e5697863f0..e74b6528cc 100644 --- a/backend/ent/channelmonitor.go +++ b/backend/ent/channelmonitor.go @@ -27,9 +27,13 @@ type ChannelMonitor struct { Name string `json:"name,omitempty"` // Provider holds the value of the "provider" field. Provider channelmonitor.Provider `json:"provider,omitempty"` + // probe = LLM probe (default); quota = account usage only; quota_probe = both + CheckMode string `json:"check_mode,omitempty"` + // AccountID holds the value of the "account_id" field. + AccountID *int64 `json:"account_id,omitempty"` // OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions APIMode string `json:"api_mode,omitempty"` - // Provider base origin, e.g. https://api.openai.com + // Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors Endpoint string `json:"endpoint,omitempty"` // AES-256-GCM encrypted API key APIKeyEncrypted string `json:"-"` @@ -114,9 +118,9 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case channelmonitor.FieldEnabled: values[i] = new(sql.NullBool) - case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID: + case channelmonitor.FieldID, channelmonitor.FieldAccountID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID: values[i] = new(sql.NullInt64) - case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode: + case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldCheckMode, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode: values[i] = new(sql.NullString) case channelmonitor.FieldCreatedAt, channelmonitor.FieldUpdatedAt, channelmonitor.FieldLastCheckedAt: values[i] = new(sql.NullTime) @@ -165,6 +169,19 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Provider = channelmonitor.Provider(value.String) } + case channelmonitor.FieldCheckMode: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field check_mode", values[i]) + } else if value.Valid { + _m.CheckMode = value.String + } + case channelmonitor.FieldAccountID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field account_id", values[i]) + } else if value.Valid { + _m.AccountID = new(int64) + *_m.AccountID = value.Int64 + } case channelmonitor.FieldAPIMode: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field api_mode", values[i]) @@ -326,6 +343,14 @@ func (_m *ChannelMonitor) String() string { builder.WriteString("provider=") builder.WriteString(fmt.Sprintf("%v", _m.Provider)) builder.WriteString(", ") + builder.WriteString("check_mode=") + builder.WriteString(_m.CheckMode) + builder.WriteString(", ") + if v := _m.AccountID; v != nil { + builder.WriteString("account_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("api_mode=") builder.WriteString(_m.APIMode) builder.WriteString(", ") diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index 711e6217e2..3224c6eb04 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -23,6 +23,10 @@ const ( FieldName = "name" // FieldProvider holds the string denoting the provider field in the database. FieldProvider = "provider" + // FieldCheckMode holds the string denoting the check_mode field in the database. + FieldCheckMode = "check_mode" + // FieldAccountID holds the string denoting the account_id field in the database. + FieldAccountID = "account_id" // FieldAPIMode holds the string denoting the api_mode field in the database. FieldAPIMode = "api_mode" // FieldEndpoint holds the string denoting the endpoint field in the database. @@ -91,6 +95,8 @@ var Columns = []string{ FieldUpdatedAt, FieldName, FieldProvider, + FieldCheckMode, + FieldAccountID, FieldAPIMode, FieldEndpoint, FieldAPIKeyEncrypted, @@ -127,6 +133,10 @@ var ( UpdateDefaultUpdatedAt func() time.Time // NameValidator is a validator for the "name" field. It is called by the builders before save. NameValidator func(string) error + // DefaultCheckMode holds the default value on creation for the "check_mode" field. + DefaultCheckMode string + // CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save. + CheckModeValidator func(string) error // DefaultAPIMode holds the default value on creation for the "api_mode" field. DefaultAPIMode string // APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save. @@ -164,10 +174,14 @@ type Provider string // Provider values. const ( - ProviderOpenai Provider = "openai" - ProviderAnthropic Provider = "anthropic" - ProviderGemini Provider = "gemini" - ProviderGrok Provider = "grok" + ProviderOpenai Provider = "openai" + ProviderAnthropic Provider = "anthropic" + ProviderGemini Provider = "gemini" + ProviderGrok Provider = "grok" + ProviderAntigravity Provider = "antigravity" + ProviderKimi Provider = "kimi" + ProviderZhipu Provider = "zhipu" + ProviderDeepseek Provider = "deepseek" ) func (pr Provider) String() string { @@ -177,7 +191,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek: return nil default: return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr) @@ -212,6 +226,16 @@ func ByProvider(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldProvider, opts...).ToFunc() } +// ByCheckMode orders the results by the check_mode field. +func ByCheckMode(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCheckMode, opts...).ToFunc() +} + +// ByAccountID orders the results by the account_id field. +func ByAccountID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAccountID, opts...).ToFunc() +} + // ByAPIMode orders the results by the api_mode field. func ByAPIMode(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAPIMode, opts...).ToFunc() diff --git a/backend/ent/channelmonitor/where.go b/backend/ent/channelmonitor/where.go index 6575aa5df6..9370a0b5ea 100644 --- a/backend/ent/channelmonitor/where.go +++ b/backend/ent/channelmonitor/where.go @@ -70,6 +70,16 @@ func Name(v string) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldName, v)) } +// CheckMode applies equality check predicate on the "check_mode" field. It's identical to CheckModeEQ. +func CheckMode(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v)) +} + +// AccountID applies equality check predicate on the "account_id" field. It's identical to AccountIDEQ. +func AccountID(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v)) +} + // APIMode applies equality check predicate on the "api_mode" field. It's identical to APIModeEQ. func APIMode(v string) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v)) @@ -295,6 +305,121 @@ func ProviderNotIn(vs ...Provider) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldNotIn(FieldProvider, vs...)) } +// CheckModeEQ applies the EQ predicate on the "check_mode" field. +func CheckModeEQ(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v)) +} + +// CheckModeNEQ applies the NEQ predicate on the "check_mode" field. +func CheckModeNEQ(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNEQ(FieldCheckMode, v)) +} + +// CheckModeIn applies the In predicate on the "check_mode" field. +func CheckModeIn(vs ...string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIn(FieldCheckMode, vs...)) +} + +// CheckModeNotIn applies the NotIn predicate on the "check_mode" field. +func CheckModeNotIn(vs ...string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotIn(FieldCheckMode, vs...)) +} + +// CheckModeGT applies the GT predicate on the "check_mode" field. +func CheckModeGT(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGT(FieldCheckMode, v)) +} + +// CheckModeGTE applies the GTE predicate on the "check_mode" field. +func CheckModeGTE(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGTE(FieldCheckMode, v)) +} + +// CheckModeLT applies the LT predicate on the "check_mode" field. +func CheckModeLT(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLT(FieldCheckMode, v)) +} + +// CheckModeLTE applies the LTE predicate on the "check_mode" field. +func CheckModeLTE(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLTE(FieldCheckMode, v)) +} + +// CheckModeContains applies the Contains predicate on the "check_mode" field. +func CheckModeContains(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldContains(FieldCheckMode, v)) +} + +// CheckModeHasPrefix applies the HasPrefix predicate on the "check_mode" field. +func CheckModeHasPrefix(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldHasPrefix(FieldCheckMode, v)) +} + +// CheckModeHasSuffix applies the HasSuffix predicate on the "check_mode" field. +func CheckModeHasSuffix(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldHasSuffix(FieldCheckMode, v)) +} + +// CheckModeEqualFold applies the EqualFold predicate on the "check_mode" field. +func CheckModeEqualFold(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEqualFold(FieldCheckMode, v)) +} + +// CheckModeContainsFold applies the ContainsFold predicate on the "check_mode" field. +func CheckModeContainsFold(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldContainsFold(FieldCheckMode, v)) +} + +// AccountIDEQ applies the EQ predicate on the "account_id" field. +func AccountIDEQ(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v)) +} + +// AccountIDNEQ applies the NEQ predicate on the "account_id" field. +func AccountIDNEQ(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNEQ(FieldAccountID, v)) +} + +// AccountIDIn applies the In predicate on the "account_id" field. +func AccountIDIn(vs ...int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIn(FieldAccountID, vs...)) +} + +// AccountIDNotIn applies the NotIn predicate on the "account_id" field. +func AccountIDNotIn(vs ...int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotIn(FieldAccountID, vs...)) +} + +// AccountIDGT applies the GT predicate on the "account_id" field. +func AccountIDGT(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGT(FieldAccountID, v)) +} + +// AccountIDGTE applies the GTE predicate on the "account_id" field. +func AccountIDGTE(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGTE(FieldAccountID, v)) +} + +// AccountIDLT applies the LT predicate on the "account_id" field. +func AccountIDLT(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLT(FieldAccountID, v)) +} + +// AccountIDLTE applies the LTE predicate on the "account_id" field. +func AccountIDLTE(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLTE(FieldAccountID, v)) +} + +// AccountIDIsNil applies the IsNil predicate on the "account_id" field. +func AccountIDIsNil() predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIsNull(FieldAccountID)) +} + +// AccountIDNotNil applies the NotNil predicate on the "account_id" field. +func AccountIDNotNil() predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotNull(FieldAccountID)) +} + // APIModeEQ applies the EQ predicate on the "api_mode" field. func APIModeEQ(v string) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v)) diff --git a/backend/ent/channelmonitor_create.go b/backend/ent/channelmonitor_create.go index 6c7e654917..cfd63437ff 100644 --- a/backend/ent/channelmonitor_create.go +++ b/backend/ent/channelmonitor_create.go @@ -65,6 +65,34 @@ func (_c *ChannelMonitorCreate) SetProvider(v channelmonitor.Provider) *ChannelM return _c } +// SetCheckMode sets the "check_mode" field. +func (_c *ChannelMonitorCreate) SetCheckMode(v string) *ChannelMonitorCreate { + _c.mutation.SetCheckMode(v) + return _c +} + +// SetNillableCheckMode sets the "check_mode" field if the given value is not nil. +func (_c *ChannelMonitorCreate) SetNillableCheckMode(v *string) *ChannelMonitorCreate { + if v != nil { + _c.SetCheckMode(*v) + } + return _c +} + +// SetAccountID sets the "account_id" field. +func (_c *ChannelMonitorCreate) SetAccountID(v int64) *ChannelMonitorCreate { + _c.mutation.SetAccountID(v) + return _c +} + +// SetNillableAccountID sets the "account_id" field if the given value is not nil. +func (_c *ChannelMonitorCreate) SetNillableAccountID(v *int64) *ChannelMonitorCreate { + if v != nil { + _c.SetAccountID(*v) + } + return _c +} + // SetAPIMode sets the "api_mode" field. func (_c *ChannelMonitorCreate) SetAPIMode(v string) *ChannelMonitorCreate { _c.mutation.SetAPIMode(v) @@ -303,6 +331,10 @@ func (_c *ChannelMonitorCreate) defaults() { v := channelmonitor.DefaultUpdatedAt() _c.mutation.SetUpdatedAt(v) } + if _, ok := _c.mutation.CheckMode(); !ok { + v := channelmonitor.DefaultCheckMode + _c.mutation.SetCheckMode(v) + } if _, ok := _c.mutation.APIMode(); !ok { v := channelmonitor.DefaultAPIMode _c.mutation.SetAPIMode(v) @@ -357,6 +389,14 @@ func (_c *ChannelMonitorCreate) check() error { return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)} } } + if _, ok := _c.mutation.CheckMode(); !ok { + return &ValidationError{Name: "check_mode", err: errors.New(`ent: missing required field "ChannelMonitor.check_mode"`)} + } + if v, ok := _c.mutation.CheckMode(); ok { + if err := channelmonitor.CheckModeValidator(v); err != nil { + return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)} + } + } if _, ok := _c.mutation.APIMode(); !ok { return &ValidationError{Name: "api_mode", err: errors.New(`ent: missing required field "ChannelMonitor.api_mode"`)} } @@ -473,6 +513,14 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS _spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value) _node.Provider = value } + if value, ok := _c.mutation.CheckMode(); ok { + _spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value) + _node.CheckMode = value + } + if value, ok := _c.mutation.AccountID(); ok { + _spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value) + _node.AccountID = &value + } if value, ok := _c.mutation.APIMode(); ok { _spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value) _node.APIMode = value @@ -666,6 +714,42 @@ func (u *ChannelMonitorUpsert) UpdateProvider() *ChannelMonitorUpsert { return u } +// SetCheckMode sets the "check_mode" field. +func (u *ChannelMonitorUpsert) SetCheckMode(v string) *ChannelMonitorUpsert { + u.Set(channelmonitor.FieldCheckMode, v) + return u +} + +// UpdateCheckMode sets the "check_mode" field to the value that was provided on create. +func (u *ChannelMonitorUpsert) UpdateCheckMode() *ChannelMonitorUpsert { + u.SetExcluded(channelmonitor.FieldCheckMode) + return u +} + +// SetAccountID sets the "account_id" field. +func (u *ChannelMonitorUpsert) SetAccountID(v int64) *ChannelMonitorUpsert { + u.Set(channelmonitor.FieldAccountID, v) + return u +} + +// UpdateAccountID sets the "account_id" field to the value that was provided on create. +func (u *ChannelMonitorUpsert) UpdateAccountID() *ChannelMonitorUpsert { + u.SetExcluded(channelmonitor.FieldAccountID) + return u +} + +// AddAccountID adds v to the "account_id" field. +func (u *ChannelMonitorUpsert) AddAccountID(v int64) *ChannelMonitorUpsert { + u.Add(channelmonitor.FieldAccountID, v) + return u +} + +// ClearAccountID clears the value of the "account_id" field. +func (u *ChannelMonitorUpsert) ClearAccountID() *ChannelMonitorUpsert { + u.SetNull(channelmonitor.FieldAccountID) + return u +} + // SetAPIMode sets the "api_mode" field. func (u *ChannelMonitorUpsert) SetAPIMode(v string) *ChannelMonitorUpsert { u.Set(channelmonitor.FieldAPIMode, v) @@ -975,6 +1059,48 @@ func (u *ChannelMonitorUpsertOne) UpdateProvider() *ChannelMonitorUpsertOne { }) } +// SetCheckMode sets the "check_mode" field. +func (u *ChannelMonitorUpsertOne) SetCheckMode(v string) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetCheckMode(v) + }) +} + +// UpdateCheckMode sets the "check_mode" field to the value that was provided on create. +func (u *ChannelMonitorUpsertOne) UpdateCheckMode() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateCheckMode() + }) +} + +// SetAccountID sets the "account_id" field. +func (u *ChannelMonitorUpsertOne) SetAccountID(v int64) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetAccountID(v) + }) +} + +// AddAccountID adds v to the "account_id" field. +func (u *ChannelMonitorUpsertOne) AddAccountID(v int64) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.AddAccountID(v) + }) +} + +// UpdateAccountID sets the "account_id" field to the value that was provided on create. +func (u *ChannelMonitorUpsertOne) UpdateAccountID() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateAccountID() + }) +} + +// ClearAccountID clears the value of the "account_id" field. +func (u *ChannelMonitorUpsertOne) ClearAccountID() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.ClearAccountID() + }) +} + // SetAPIMode sets the "api_mode" field. func (u *ChannelMonitorUpsertOne) SetAPIMode(v string) *ChannelMonitorUpsertOne { return u.Update(func(s *ChannelMonitorUpsert) { @@ -1487,6 +1613,48 @@ func (u *ChannelMonitorUpsertBulk) UpdateProvider() *ChannelMonitorUpsertBulk { }) } +// SetCheckMode sets the "check_mode" field. +func (u *ChannelMonitorUpsertBulk) SetCheckMode(v string) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetCheckMode(v) + }) +} + +// UpdateCheckMode sets the "check_mode" field to the value that was provided on create. +func (u *ChannelMonitorUpsertBulk) UpdateCheckMode() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateCheckMode() + }) +} + +// SetAccountID sets the "account_id" field. +func (u *ChannelMonitorUpsertBulk) SetAccountID(v int64) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetAccountID(v) + }) +} + +// AddAccountID adds v to the "account_id" field. +func (u *ChannelMonitorUpsertBulk) AddAccountID(v int64) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.AddAccountID(v) + }) +} + +// UpdateAccountID sets the "account_id" field to the value that was provided on create. +func (u *ChannelMonitorUpsertBulk) UpdateAccountID() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateAccountID() + }) +} + +// ClearAccountID clears the value of the "account_id" field. +func (u *ChannelMonitorUpsertBulk) ClearAccountID() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.ClearAccountID() + }) +} + // SetAPIMode sets the "api_mode" field. func (u *ChannelMonitorUpsertBulk) SetAPIMode(v string) *ChannelMonitorUpsertBulk { return u.Update(func(s *ChannelMonitorUpsert) { diff --git a/backend/ent/channelmonitor_update.go b/backend/ent/channelmonitor_update.go index ccc6fa8cc2..f5b52e4e1d 100644 --- a/backend/ent/channelmonitor_update.go +++ b/backend/ent/channelmonitor_update.go @@ -66,6 +66,47 @@ func (_u *ChannelMonitorUpdate) SetNillableProvider(v *channelmonitor.Provider) return _u } +// SetCheckMode sets the "check_mode" field. +func (_u *ChannelMonitorUpdate) SetCheckMode(v string) *ChannelMonitorUpdate { + _u.mutation.SetCheckMode(v) + return _u +} + +// SetNillableCheckMode sets the "check_mode" field if the given value is not nil. +func (_u *ChannelMonitorUpdate) SetNillableCheckMode(v *string) *ChannelMonitorUpdate { + if v != nil { + _u.SetCheckMode(*v) + } + return _u +} + +// SetAccountID sets the "account_id" field. +func (_u *ChannelMonitorUpdate) SetAccountID(v int64) *ChannelMonitorUpdate { + _u.mutation.ResetAccountID() + _u.mutation.SetAccountID(v) + return _u +} + +// SetNillableAccountID sets the "account_id" field if the given value is not nil. +func (_u *ChannelMonitorUpdate) SetNillableAccountID(v *int64) *ChannelMonitorUpdate { + if v != nil { + _u.SetAccountID(*v) + } + return _u +} + +// AddAccountID adds value to the "account_id" field. +func (_u *ChannelMonitorUpdate) AddAccountID(v int64) *ChannelMonitorUpdate { + _u.mutation.AddAccountID(v) + return _u +} + +// ClearAccountID clears the value of the "account_id" field. +func (_u *ChannelMonitorUpdate) ClearAccountID() *ChannelMonitorUpdate { + _u.mutation.ClearAccountID() + return _u +} + // SetAPIMode sets the "api_mode" field. func (_u *ChannelMonitorUpdate) SetAPIMode(v string) *ChannelMonitorUpdate { _u.mutation.SetAPIMode(v) @@ -453,6 +494,11 @@ func (_u *ChannelMonitorUpdate) check() error { return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)} } } + if v, ok := _u.mutation.CheckMode(); ok { + if err := channelmonitor.CheckModeValidator(v); err != nil { + return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)} + } + } if v, ok := _u.mutation.APIMode(); ok { if err := channelmonitor.APIModeValidator(v); err != nil { return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)} @@ -517,6 +563,18 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err if value, ok := _u.mutation.Provider(); ok { _spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value) } + if value, ok := _u.mutation.CheckMode(); ok { + _spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value) + } + if value, ok := _u.mutation.AccountID(); ok { + _spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedAccountID(); ok { + _spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if _u.mutation.AccountIDCleared() { + _spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64) + } if value, ok := _u.mutation.APIMode(); ok { _spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value) } @@ -755,6 +813,47 @@ func (_u *ChannelMonitorUpdateOne) SetNillableProvider(v *channelmonitor.Provide return _u } +// SetCheckMode sets the "check_mode" field. +func (_u *ChannelMonitorUpdateOne) SetCheckMode(v string) *ChannelMonitorUpdateOne { + _u.mutation.SetCheckMode(v) + return _u +} + +// SetNillableCheckMode sets the "check_mode" field if the given value is not nil. +func (_u *ChannelMonitorUpdateOne) SetNillableCheckMode(v *string) *ChannelMonitorUpdateOne { + if v != nil { + _u.SetCheckMode(*v) + } + return _u +} + +// SetAccountID sets the "account_id" field. +func (_u *ChannelMonitorUpdateOne) SetAccountID(v int64) *ChannelMonitorUpdateOne { + _u.mutation.ResetAccountID() + _u.mutation.SetAccountID(v) + return _u +} + +// SetNillableAccountID sets the "account_id" field if the given value is not nil. +func (_u *ChannelMonitorUpdateOne) SetNillableAccountID(v *int64) *ChannelMonitorUpdateOne { + if v != nil { + _u.SetAccountID(*v) + } + return _u +} + +// AddAccountID adds value to the "account_id" field. +func (_u *ChannelMonitorUpdateOne) AddAccountID(v int64) *ChannelMonitorUpdateOne { + _u.mutation.AddAccountID(v) + return _u +} + +// ClearAccountID clears the value of the "account_id" field. +func (_u *ChannelMonitorUpdateOne) ClearAccountID() *ChannelMonitorUpdateOne { + _u.mutation.ClearAccountID() + return _u +} + // SetAPIMode sets the "api_mode" field. func (_u *ChannelMonitorUpdateOne) SetAPIMode(v string) *ChannelMonitorUpdateOne { _u.mutation.SetAPIMode(v) @@ -1155,6 +1254,11 @@ func (_u *ChannelMonitorUpdateOne) check() error { return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)} } } + if v, ok := _u.mutation.CheckMode(); ok { + if err := channelmonitor.CheckModeValidator(v); err != nil { + return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)} + } + } if v, ok := _u.mutation.APIMode(); ok { if err := channelmonitor.APIModeValidator(v); err != nil { return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)} @@ -1236,6 +1340,18 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM if value, ok := _u.mutation.Provider(); ok { _spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value) } + if value, ok := _u.mutation.CheckMode(); ok { + _spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value) + } + if value, ok := _u.mutation.AccountID(); ok { + _spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedAccountID(); ok { + _spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if _u.mutation.AccountIDCleared() { + _spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64) + } if value, ok := _u.mutation.APIMode(); ok { _spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value) } diff --git a/backend/ent/channelmonitorhistory.go b/backend/ent/channelmonitorhistory.go index 70dde5422a..fef5109528 100644 --- a/backend/ent/channelmonitorhistory.go +++ b/backend/ent/channelmonitorhistory.go @@ -3,6 +3,7 @@ package ent import ( + "encoding/json" "fmt" "strings" "time" @@ -11,6 +12,7 @@ import ( "entgo.io/ent/dialect/sql" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // ChannelMonitorHistory is the model entity for the ChannelMonitorHistory schema. @@ -30,6 +32,8 @@ type ChannelMonitorHistory struct { PingLatencyMs *int `json:"ping_latency_ms,omitempty"` // Message holds the value of the "message" field. Message string `json:"message,omitempty"` + // Quota holds the value of the "quota" field. + Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"` // CheckedAt holds the value of the "checked_at" field. CheckedAt time.Time `json:"checked_at,omitempty"` // Edges holds the relations/edges for other nodes in the graph. @@ -63,6 +67,8 @@ func (*ChannelMonitorHistory) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { + case channelmonitorhistory.FieldQuota: + values[i] = new([]byte) case channelmonitorhistory.FieldID, channelmonitorhistory.FieldMonitorID, channelmonitorhistory.FieldLatencyMs, channelmonitorhistory.FieldPingLatencyMs: values[i] = new(sql.NullInt64) case channelmonitorhistory.FieldModel, channelmonitorhistory.FieldStatus, channelmonitorhistory.FieldMessage: @@ -128,6 +134,14 @@ func (_m *ChannelMonitorHistory) assignValues(columns []string, values []any) er } else if value.Valid { _m.Message = value.String } + case channelmonitorhistory.FieldQuota: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field quota", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.Quota); err != nil { + return fmt.Errorf("unmarshal field quota: %w", err) + } + } case channelmonitorhistory.FieldCheckedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field checked_at", values[i]) @@ -197,6 +211,9 @@ func (_m *ChannelMonitorHistory) String() string { builder.WriteString("message=") builder.WriteString(_m.Message) builder.WriteString(", ") + builder.WriteString("quota=") + builder.WriteString(fmt.Sprintf("%v", _m.Quota)) + builder.WriteString(", ") builder.WriteString("checked_at=") builder.WriteString(_m.CheckedAt.Format(time.ANSIC)) builder.WriteByte(')') diff --git a/backend/ent/channelmonitorhistory/channelmonitorhistory.go b/backend/ent/channelmonitorhistory/channelmonitorhistory.go index 6a9dc00670..ceb11cee67 100644 --- a/backend/ent/channelmonitorhistory/channelmonitorhistory.go +++ b/backend/ent/channelmonitorhistory/channelmonitorhistory.go @@ -27,6 +27,8 @@ const ( FieldPingLatencyMs = "ping_latency_ms" // FieldMessage holds the string denoting the message field in the database. FieldMessage = "message" + // FieldQuota holds the string denoting the quota field in the database. + FieldQuota = "quota" // FieldCheckedAt holds the string denoting the checked_at field in the database. FieldCheckedAt = "checked_at" // EdgeMonitor holds the string denoting the monitor edge name in mutations. @@ -51,6 +53,7 @@ var Columns = []string{ FieldLatencyMs, FieldPingLatencyMs, FieldMessage, + FieldQuota, FieldCheckedAt, } diff --git a/backend/ent/channelmonitorhistory/where.go b/backend/ent/channelmonitorhistory/where.go index afa73f35c8..f7afd7c319 100644 --- a/backend/ent/channelmonitorhistory/where.go +++ b/backend/ent/channelmonitorhistory/where.go @@ -365,6 +365,16 @@ func MessageContainsFold(v string) predicate.ChannelMonitorHistory { return predicate.ChannelMonitorHistory(sql.FieldContainsFold(FieldMessage, v)) } +// QuotaIsNil applies the IsNil predicate on the "quota" field. +func QuotaIsNil() predicate.ChannelMonitorHistory { + return predicate.ChannelMonitorHistory(sql.FieldIsNull(FieldQuota)) +} + +// QuotaNotNil applies the NotNil predicate on the "quota" field. +func QuotaNotNil() predicate.ChannelMonitorHistory { + return predicate.ChannelMonitorHistory(sql.FieldNotNull(FieldQuota)) +} + // CheckedAtEQ applies the EQ predicate on the "checked_at" field. func CheckedAtEQ(v time.Time) predicate.ChannelMonitorHistory { return predicate.ChannelMonitorHistory(sql.FieldEQ(FieldCheckedAt, v)) diff --git a/backend/ent/channelmonitorhistory_create.go b/backend/ent/channelmonitorhistory_create.go index 71034865c9..4d4222d6d3 100644 --- a/backend/ent/channelmonitorhistory_create.go +++ b/backend/ent/channelmonitorhistory_create.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // ChannelMonitorHistoryCreate is the builder for creating a ChannelMonitorHistory entity. @@ -83,6 +84,12 @@ func (_c *ChannelMonitorHistoryCreate) SetNillableMessage(v *string) *ChannelMon return _c } +// SetQuota sets the "quota" field. +func (_c *ChannelMonitorHistoryCreate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryCreate { + _c.mutation.SetQuota(v) + return _c +} + // SetCheckedAt sets the "checked_at" field. func (_c *ChannelMonitorHistoryCreate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryCreate { _c.mutation.SetCheckedAt(v) @@ -226,6 +233,10 @@ func (_c *ChannelMonitorHistoryCreate) createSpec() (*ChannelMonitorHistory, *sq _spec.SetField(channelmonitorhistory.FieldMessage, field.TypeString, value) _node.Message = value } + if value, ok := _c.mutation.Quota(); ok { + _spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value) + _node.Quota = value + } if value, ok := _c.mutation.CheckedAt(); ok { _spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value) _node.CheckedAt = value @@ -401,6 +412,24 @@ func (u *ChannelMonitorHistoryUpsert) ClearMessage() *ChannelMonitorHistoryUpser return u } +// SetQuota sets the "quota" field. +func (u *ChannelMonitorHistoryUpsert) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsert { + u.Set(channelmonitorhistory.FieldQuota, v) + return u +} + +// UpdateQuota sets the "quota" field to the value that was provided on create. +func (u *ChannelMonitorHistoryUpsert) UpdateQuota() *ChannelMonitorHistoryUpsert { + u.SetExcluded(channelmonitorhistory.FieldQuota) + return u +} + +// ClearQuota clears the value of the "quota" field. +func (u *ChannelMonitorHistoryUpsert) ClearQuota() *ChannelMonitorHistoryUpsert { + u.SetNull(channelmonitorhistory.FieldQuota) + return u +} + // SetCheckedAt sets the "checked_at" field. func (u *ChannelMonitorHistoryUpsert) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsert { u.Set(channelmonitorhistory.FieldCheckedAt, v) @@ -572,6 +601,27 @@ func (u *ChannelMonitorHistoryUpsertOne) ClearMessage() *ChannelMonitorHistoryUp }) } +// SetQuota sets the "quota" field. +func (u *ChannelMonitorHistoryUpsertOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertOne { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.SetQuota(v) + }) +} + +// UpdateQuota sets the "quota" field to the value that was provided on create. +func (u *ChannelMonitorHistoryUpsertOne) UpdateQuota() *ChannelMonitorHistoryUpsertOne { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.UpdateQuota() + }) +} + +// ClearQuota clears the value of the "quota" field. +func (u *ChannelMonitorHistoryUpsertOne) ClearQuota() *ChannelMonitorHistoryUpsertOne { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.ClearQuota() + }) +} + // SetCheckedAt sets the "checked_at" field. func (u *ChannelMonitorHistoryUpsertOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertOne { return u.Update(func(s *ChannelMonitorHistoryUpsert) { @@ -909,6 +959,27 @@ func (u *ChannelMonitorHistoryUpsertBulk) ClearMessage() *ChannelMonitorHistoryU }) } +// SetQuota sets the "quota" field. +func (u *ChannelMonitorHistoryUpsertBulk) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertBulk { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.SetQuota(v) + }) +} + +// UpdateQuota sets the "quota" field to the value that was provided on create. +func (u *ChannelMonitorHistoryUpsertBulk) UpdateQuota() *ChannelMonitorHistoryUpsertBulk { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.UpdateQuota() + }) +} + +// ClearQuota clears the value of the "quota" field. +func (u *ChannelMonitorHistoryUpsertBulk) ClearQuota() *ChannelMonitorHistoryUpsertBulk { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.ClearQuota() + }) +} + // SetCheckedAt sets the "checked_at" field. func (u *ChannelMonitorHistoryUpsertBulk) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertBulk { return u.Update(func(s *ChannelMonitorHistoryUpsert) { diff --git a/backend/ent/channelmonitorhistory_update.go b/backend/ent/channelmonitorhistory_update.go index a85a8072a9..58d7214068 100644 --- a/backend/ent/channelmonitorhistory_update.go +++ b/backend/ent/channelmonitorhistory_update.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // ChannelMonitorHistoryUpdate is the builder for updating ChannelMonitorHistory entities. @@ -145,6 +146,18 @@ func (_u *ChannelMonitorHistoryUpdate) ClearMessage() *ChannelMonitorHistoryUpda return _u } +// SetQuota sets the "quota" field. +func (_u *ChannelMonitorHistoryUpdate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdate { + _u.mutation.SetQuota(v) + return _u +} + +// ClearQuota clears the value of the "quota" field. +func (_u *ChannelMonitorHistoryUpdate) ClearQuota() *ChannelMonitorHistoryUpdate { + _u.mutation.ClearQuota() + return _u +} + // SetCheckedAt sets the "checked_at" field. func (_u *ChannelMonitorHistoryUpdate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdate { _u.mutation.SetCheckedAt(v) @@ -267,6 +280,12 @@ func (_u *ChannelMonitorHistoryUpdate) sqlSave(ctx context.Context) (_node int, if _u.mutation.MessageCleared() { _spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString) } + if value, ok := _u.mutation.Quota(); ok { + _spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value) + } + if _u.mutation.QuotaCleared() { + _spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON) + } if value, ok := _u.mutation.CheckedAt(); ok { _spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value) } @@ -435,6 +454,18 @@ func (_u *ChannelMonitorHistoryUpdateOne) ClearMessage() *ChannelMonitorHistoryU return _u } +// SetQuota sets the "quota" field. +func (_u *ChannelMonitorHistoryUpdateOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdateOne { + _u.mutation.SetQuota(v) + return _u +} + +// ClearQuota clears the value of the "quota" field. +func (_u *ChannelMonitorHistoryUpdateOne) ClearQuota() *ChannelMonitorHistoryUpdateOne { + _u.mutation.ClearQuota() + return _u +} + // SetCheckedAt sets the "checked_at" field. func (_u *ChannelMonitorHistoryUpdateOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdateOne { _u.mutation.SetCheckedAt(v) @@ -587,6 +618,12 @@ func (_u *ChannelMonitorHistoryUpdateOne) sqlSave(ctx context.Context) (_node *C if _u.mutation.MessageCleared() { _spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString) } + if value, ok := _u.mutation.Quota(); ok { + _spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value) + } + if _u.mutation.QuotaCleared() { + _spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON) + } if value, ok := _u.mutation.CheckedAt(); ok { _spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value) } diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index 5989d0e743..f0e9dbf16e 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -100,10 +100,14 @@ type Provider string // Provider values. const ( - ProviderOpenai Provider = "openai" - ProviderAnthropic Provider = "anthropic" - ProviderGemini Provider = "gemini" - ProviderGrok Provider = "grok" + ProviderOpenai Provider = "openai" + ProviderAnthropic Provider = "anthropic" + ProviderGemini Provider = "gemini" + ProviderGrok Provider = "grok" + ProviderAntigravity Provider = "antigravity" + ProviderKimi Provider = "kimi" + ProviderZhipu Provider = "zhipu" + ProviderDeepseek Provider = "deepseek" ) func (pr Provider) String() string { @@ -113,7 +117,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek: return nil default: return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/group.go b/backend/ent/group.go index 110f06742a..8f30c26470 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -4,6 +4,7 @@ package ent import ( "encoding/json" + "encoding/json/jsontext" "fmt" "strings" "time" @@ -97,10 +98,10 @@ type Group struct { AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` // STT 每小时价格(USD) AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` - // 是否按上下文长度应用模型阶梯价格 + // 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价 LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"` // 分组逐模型定价;优先级高于渠道和内置定价 - ModelPricing json.RawMessage `json:"model_pricing,omitempty"` + ModelPricing jsontext.Value `json:"model_pricing,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 158dca1e40..e5e92ccd3f 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -575,7 +575,7 @@ func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreat } // SetModelPricing sets the "model_pricing" field. -func (_c *GroupCreate) SetModelPricing(v json.RawMessage) *GroupCreate { +func (_c *GroupCreate) SetModelPricing(v jsontext.Value) *GroupCreate { _c.mutation.SetModelPricing(v) return _c } @@ -2445,7 +2445,7 @@ func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsert) SetModelPricing(v json.RawMessage) *GroupUpsert { +func (u *GroupUpsert) SetModelPricing(v jsontext.Value) *GroupUpsert { u.Set(group.FieldModelPricing, v) return u } @@ -3615,7 +3615,7 @@ func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsertOne) SetModelPricing(v json.RawMessage) *GroupUpsertOne { +func (u *GroupUpsertOne) SetModelPricing(v jsontext.Value) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { s.SetModelPricing(v) }) @@ -5005,7 +5005,7 @@ func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsertBulk) SetModelPricing(v json.RawMessage) *GroupUpsertBulk { +func (u *GroupUpsertBulk) SetModelPricing(v jsontext.Value) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { s.SetModelPricing(v) }) diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index beca0c71f0..41e06b4471 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -803,13 +803,13 @@ func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdat } // SetModelPricing sets the "model_pricing" field. -func (_u *GroupUpdate) SetModelPricing(v json.RawMessage) *GroupUpdate { +func (_u *GroupUpdate) SetModelPricing(v jsontext.Value) *GroupUpdate { _u.mutation.SetModelPricing(v) return _u } // AppendModelPricing appends value to the "model_pricing" field. -func (_u *GroupUpdate) AppendModelPricing(v json.RawMessage) *GroupUpdate { +func (_u *GroupUpdate) AppendModelPricing(v jsontext.Value) *GroupUpdate { _u.mutation.AppendModelPricing(v) return _u } @@ -2924,13 +2924,13 @@ func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUp } // SetModelPricing sets the "model_pricing" field. -func (_u *GroupUpdateOne) SetModelPricing(v json.RawMessage) *GroupUpdateOne { +func (_u *GroupUpdateOne) SetModelPricing(v jsontext.Value) *GroupUpdateOne { _u.mutation.SetModelPricing(v) return _u } // AppendModelPricing appends value to the "model_pricing" field. -func (_u *GroupUpdateOne) AppendModelPricing(v json.RawMessage) *GroupUpdateOne { +func (_u *GroupUpdateOne) AppendModelPricing(v jsontext.Value) *GroupUpdateOne { _u.mutation.AppendModelPricing(v) return _u } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 316870e5a5..f667c3ffbf 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -623,7 +623,9 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}}, + {Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"}, + {Name: "account_id", Type: field.TypeInt64, Nullable: true}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, {Name: "endpoint", Type: field.TypeString, Size: 500}, {Name: "api_key_encrypted", Type: field.TypeString}, @@ -648,7 +650,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "channel_monitors_channel_monitor_request_templates_request_template", - Columns: []*schema.Column{ChannelMonitorsColumns[19]}, + Columns: []*schema.Column{ChannelMonitorsColumns[21]}, RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]}, OnDelete: schema.SetNull, }, @@ -657,7 +659,7 @@ var ( { Name: "channelmonitor_enabled_last_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]}, + Columns: []*schema.Column{ChannelMonitorsColumns[13], ChannelMonitorsColumns[16]}, }, { Name: "channelmonitor_provider", @@ -667,17 +669,22 @@ var ( { Name: "channelmonitor_provider_api_mode", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[5]}, + Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[7]}, }, { Name: "channelmonitor_group_name", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[10]}, + Columns: []*schema.Column{ChannelMonitorsColumns[12]}, }, { Name: "channelmonitor_template_id", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[19]}, + Columns: []*schema.Column{ChannelMonitorsColumns[21]}, + }, + { + Name: "channelmonitor_account_id", + Unique: false, + Columns: []*schema.Column{ChannelMonitorsColumns[6]}, }, }, } @@ -733,6 +740,7 @@ var ( {Name: "latency_ms", Type: field.TypeInt, Nullable: true}, {Name: "ping_latency_ms", Type: field.TypeInt, Nullable: true}, {Name: "message", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, + {Name: "quota", Type: field.TypeJSON, Nullable: true}, {Name: "checked_at", Type: field.TypeTime}, {Name: "monitor_id", Type: field.TypeInt64}, } @@ -744,7 +752,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "channel_monitor_histories_channel_monitors_history", - Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]}, + Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8]}, RefColumns: []*schema.Column{ChannelMonitorsColumns[0]}, OnDelete: schema.Cascade, }, @@ -753,12 +761,12 @@ var ( { Name: "channelmonitorhistory_monitor_id_model_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[6]}, + Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[7]}, }, { Name: "channelmonitorhistory_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorHistoriesColumns[6]}, + Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]}, }, }, } @@ -768,7 +776,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, {Name: "extra_headers", Type: field.TypeJSON}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index da0500d9be..f777a0a5fb 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "sync" @@ -14608,6 +14608,9 @@ type ChannelMonitorMutation struct { updated_at *time.Time name *string provider *channelmonitor.Provider + check_mode *string + account_id *int64 + addaccount_id *int64 api_mode *string endpoint *string api_key_encrypted *string @@ -14882,6 +14885,112 @@ func (m *ChannelMonitorMutation) ResetProvider() { m.provider = nil } +// SetCheckMode sets the "check_mode" field. +func (m *ChannelMonitorMutation) SetCheckMode(s string) { + m.check_mode = &s +} + +// CheckMode returns the value of the "check_mode" field in the mutation. +func (m *ChannelMonitorMutation) CheckMode() (r string, exists bool) { + v := m.check_mode + if v == nil { + return + } + return *v, true +} + +// OldCheckMode returns the old "check_mode" field's value of the ChannelMonitor entity. +// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ChannelMonitorMutation) OldCheckMode(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCheckMode is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCheckMode requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCheckMode: %w", err) + } + return oldValue.CheckMode, nil +} + +// ResetCheckMode resets all changes to the "check_mode" field. +func (m *ChannelMonitorMutation) ResetCheckMode() { + m.check_mode = nil +} + +// SetAccountID sets the "account_id" field. +func (m *ChannelMonitorMutation) SetAccountID(i int64) { + m.account_id = &i + m.addaccount_id = nil +} + +// AccountID returns the value of the "account_id" field in the mutation. +func (m *ChannelMonitorMutation) AccountID() (r int64, exists bool) { + v := m.account_id + if v == nil { + return + } + return *v, true +} + +// OldAccountID returns the old "account_id" field's value of the ChannelMonitor entity. +// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ChannelMonitorMutation) OldAccountID(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAccountID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAccountID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAccountID: %w", err) + } + return oldValue.AccountID, nil +} + +// AddAccountID adds i to the "account_id" field. +func (m *ChannelMonitorMutation) AddAccountID(i int64) { + if m.addaccount_id != nil { + *m.addaccount_id += i + } else { + m.addaccount_id = &i + } +} + +// AddedAccountID returns the value that was added to the "account_id" field in this mutation. +func (m *ChannelMonitorMutation) AddedAccountID() (r int64, exists bool) { + v := m.addaccount_id + if v == nil { + return + } + return *v, true +} + +// ClearAccountID clears the value of the "account_id" field. +func (m *ChannelMonitorMutation) ClearAccountID() { + m.account_id = nil + m.addaccount_id = nil + m.clearedFields[channelmonitor.FieldAccountID] = struct{}{} +} + +// AccountIDCleared returns if the "account_id" field was cleared in this mutation. +func (m *ChannelMonitorMutation) AccountIDCleared() bool { + _, ok := m.clearedFields[channelmonitor.FieldAccountID] + return ok +} + +// ResetAccountID resets all changes to the "account_id" field. +func (m *ChannelMonitorMutation) ResetAccountID() { + m.account_id = nil + m.addaccount_id = nil + delete(m.clearedFields, channelmonitor.FieldAccountID) +} + // SetAPIMode sets the "api_mode" field. func (m *ChannelMonitorMutation) SetAPIMode(s string) { m.api_mode = &s @@ -15731,7 +15840,7 @@ func (m *ChannelMonitorMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ChannelMonitorMutation) Fields() []string { - fields := make([]string, 0, 19) + fields := make([]string, 0, 21) if m.created_at != nil { fields = append(fields, channelmonitor.FieldCreatedAt) } @@ -15744,6 +15853,12 @@ func (m *ChannelMonitorMutation) Fields() []string { if m.provider != nil { fields = append(fields, channelmonitor.FieldProvider) } + if m.check_mode != nil { + fields = append(fields, channelmonitor.FieldCheckMode) + } + if m.account_id != nil { + fields = append(fields, channelmonitor.FieldAccountID) + } if m.api_mode != nil { fields = append(fields, channelmonitor.FieldAPIMode) } @@ -15805,6 +15920,10 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) { return m.Name() case channelmonitor.FieldProvider: return m.Provider() + case channelmonitor.FieldCheckMode: + return m.CheckMode() + case channelmonitor.FieldAccountID: + return m.AccountID() case channelmonitor.FieldAPIMode: return m.APIMode() case channelmonitor.FieldEndpoint: @@ -15852,6 +15971,10 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent return m.OldName(ctx) case channelmonitor.FieldProvider: return m.OldProvider(ctx) + case channelmonitor.FieldCheckMode: + return m.OldCheckMode(ctx) + case channelmonitor.FieldAccountID: + return m.OldAccountID(ctx) case channelmonitor.FieldAPIMode: return m.OldAPIMode(ctx) case channelmonitor.FieldEndpoint: @@ -15919,6 +16042,20 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error { } m.SetProvider(v) return nil + case channelmonitor.FieldCheckMode: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCheckMode(v) + return nil + case channelmonitor.FieldAccountID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAccountID(v) + return nil case channelmonitor.FieldAPIMode: v, ok := value.(string) if !ok { @@ -16032,6 +16169,9 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error { // this mutation. func (m *ChannelMonitorMutation) AddedFields() []string { var fields []string + if m.addaccount_id != nil { + fields = append(fields, channelmonitor.FieldAccountID) + } if m.addinterval_seconds != nil { fields = append(fields, channelmonitor.FieldIntervalSeconds) } @@ -16049,6 +16189,8 @@ func (m *ChannelMonitorMutation) AddedFields() []string { // was not set, or was not defined in the schema. func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) { switch name { + case channelmonitor.FieldAccountID: + return m.AddedAccountID() case channelmonitor.FieldIntervalSeconds: return m.AddedIntervalSeconds() case channelmonitor.FieldJitterSeconds: @@ -16064,6 +16206,13 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) { // type. func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error { switch name { + case channelmonitor.FieldAccountID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAccountID(v) + return nil case channelmonitor.FieldIntervalSeconds: v, ok := value.(int) if !ok { @@ -16093,6 +16242,9 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error { // mutation. func (m *ChannelMonitorMutation) ClearedFields() []string { var fields []string + if m.FieldCleared(channelmonitor.FieldAccountID) { + fields = append(fields, channelmonitor.FieldAccountID) + } if m.FieldCleared(channelmonitor.FieldGroupName) { fields = append(fields, channelmonitor.FieldGroupName) } @@ -16119,6 +16271,9 @@ func (m *ChannelMonitorMutation) FieldCleared(name string) bool { // error if the field is not defined in the schema. func (m *ChannelMonitorMutation) ClearField(name string) error { switch name { + case channelmonitor.FieldAccountID: + m.ClearAccountID() + return nil case channelmonitor.FieldGroupName: m.ClearGroupName() return nil @@ -16151,6 +16306,12 @@ func (m *ChannelMonitorMutation) ResetField(name string) error { case channelmonitor.FieldProvider: m.ResetProvider() return nil + case channelmonitor.FieldCheckMode: + m.ResetCheckMode() + return nil + case channelmonitor.FieldAccountID: + m.ResetAccountID() + return nil case channelmonitor.FieldAPIMode: m.ResetAPIMode() return nil @@ -17756,6 +17917,7 @@ type ChannelMonitorHistoryMutation struct { ping_latency_ms *int addping_latency_ms *int message *string + quota **domain.MonitorQuotaSnapshot checked_at *time.Time clearedFields map[string]struct{} monitor *int64 @@ -18160,6 +18322,55 @@ func (m *ChannelMonitorHistoryMutation) ResetMessage() { delete(m.clearedFields, channelmonitorhistory.FieldMessage) } +// SetQuota sets the "quota" field. +func (m *ChannelMonitorHistoryMutation) SetQuota(dqs *domain.MonitorQuotaSnapshot) { + m.quota = &dqs +} + +// Quota returns the value of the "quota" field in the mutation. +func (m *ChannelMonitorHistoryMutation) Quota() (r *domain.MonitorQuotaSnapshot, exists bool) { + v := m.quota + if v == nil { + return + } + return *v, true +} + +// OldQuota returns the old "quota" field's value of the ChannelMonitorHistory entity. +// If the ChannelMonitorHistory object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ChannelMonitorHistoryMutation) OldQuota(ctx context.Context) (v *domain.MonitorQuotaSnapshot, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldQuota is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldQuota requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldQuota: %w", err) + } + return oldValue.Quota, nil +} + +// ClearQuota clears the value of the "quota" field. +func (m *ChannelMonitorHistoryMutation) ClearQuota() { + m.quota = nil + m.clearedFields[channelmonitorhistory.FieldQuota] = struct{}{} +} + +// QuotaCleared returns if the "quota" field was cleared in this mutation. +func (m *ChannelMonitorHistoryMutation) QuotaCleared() bool { + _, ok := m.clearedFields[channelmonitorhistory.FieldQuota] + return ok +} + +// ResetQuota resets all changes to the "quota" field. +func (m *ChannelMonitorHistoryMutation) ResetQuota() { + m.quota = nil + delete(m.clearedFields, channelmonitorhistory.FieldQuota) +} + // SetCheckedAt sets the "checked_at" field. func (m *ChannelMonitorHistoryMutation) SetCheckedAt(t time.Time) { m.checked_at = &t @@ -18257,7 +18468,7 @@ func (m *ChannelMonitorHistoryMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ChannelMonitorHistoryMutation) Fields() []string { - fields := make([]string, 0, 7) + fields := make([]string, 0, 8) if m.monitor != nil { fields = append(fields, channelmonitorhistory.FieldMonitorID) } @@ -18276,6 +18487,9 @@ func (m *ChannelMonitorHistoryMutation) Fields() []string { if m.message != nil { fields = append(fields, channelmonitorhistory.FieldMessage) } + if m.quota != nil { + fields = append(fields, channelmonitorhistory.FieldQuota) + } if m.checked_at != nil { fields = append(fields, channelmonitorhistory.FieldCheckedAt) } @@ -18299,6 +18513,8 @@ func (m *ChannelMonitorHistoryMutation) Field(name string) (ent.Value, bool) { return m.PingLatencyMs() case channelmonitorhistory.FieldMessage: return m.Message() + case channelmonitorhistory.FieldQuota: + return m.Quota() case channelmonitorhistory.FieldCheckedAt: return m.CheckedAt() } @@ -18322,6 +18538,8 @@ func (m *ChannelMonitorHistoryMutation) OldField(ctx context.Context, name strin return m.OldPingLatencyMs(ctx) case channelmonitorhistory.FieldMessage: return m.OldMessage(ctx) + case channelmonitorhistory.FieldQuota: + return m.OldQuota(ctx) case channelmonitorhistory.FieldCheckedAt: return m.OldCheckedAt(ctx) } @@ -18375,6 +18593,13 @@ func (m *ChannelMonitorHistoryMutation) SetField(name string, value ent.Value) e } m.SetMessage(v) return nil + case channelmonitorhistory.FieldQuota: + v, ok := value.(*domain.MonitorQuotaSnapshot) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetQuota(v) + return nil case channelmonitorhistory.FieldCheckedAt: v, ok := value.(time.Time) if !ok { @@ -18448,6 +18673,9 @@ func (m *ChannelMonitorHistoryMutation) ClearedFields() []string { if m.FieldCleared(channelmonitorhistory.FieldMessage) { fields = append(fields, channelmonitorhistory.FieldMessage) } + if m.FieldCleared(channelmonitorhistory.FieldQuota) { + fields = append(fields, channelmonitorhistory.FieldQuota) + } return fields } @@ -18471,6 +18699,9 @@ func (m *ChannelMonitorHistoryMutation) ClearField(name string) error { case channelmonitorhistory.FieldMessage: m.ClearMessage() return nil + case channelmonitorhistory.FieldQuota: + m.ClearQuota() + return nil } return fmt.Errorf("unknown ChannelMonitorHistory nullable field %s", name) } @@ -18497,6 +18728,9 @@ func (m *ChannelMonitorHistoryMutation) ResetField(name string) error { case channelmonitorhistory.FieldMessage: m.ResetMessage() return nil + case channelmonitorhistory.FieldQuota: + m.ResetQuota() + return nil case channelmonitorhistory.FieldCheckedAt: m.ResetCheckedAt() return nil @@ -21908,8 +22142,8 @@ type GroupMutation struct { audio_stt_price_per_hour *float64 addaudio_stt_price_per_hour *float64 long_context_pricing_enabled *bool - model_pricing *json.RawMessage - appendmodel_pricing json.RawMessage + model_pricing *jsontext.Value + appendmodel_pricing jsontext.Value claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -24170,13 +24404,13 @@ func (m *GroupMutation) ResetLongContextPricingEnabled() { } // SetModelPricing sets the "model_pricing" field. -func (m *GroupMutation) SetModelPricing(jm json.RawMessage) { - m.model_pricing = &jm +func (m *GroupMutation) SetModelPricing(j jsontext.Value) { + m.model_pricing = &j m.appendmodel_pricing = nil } // ModelPricing returns the value of the "model_pricing" field in the mutation. -func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { +func (m *GroupMutation) ModelPricing() (r jsontext.Value, exists bool) { v := m.model_pricing if v == nil { return @@ -24187,7 +24421,7 @@ func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { // OldModelPricing returns the old "model_pricing" field's value of the Group entity. // If the Group object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, err error) { +func (m *GroupMutation) OldModelPricing(ctx context.Context) (v jsontext.Value, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldModelPricing is only allowed on UpdateOne operations") } @@ -24201,13 +24435,13 @@ func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, return oldValue.ModelPricing, nil } -// AppendModelPricing adds jm to the "model_pricing" field. -func (m *GroupMutation) AppendModelPricing(jm json.RawMessage) { - m.appendmodel_pricing = append(m.appendmodel_pricing, jm...) +// AppendModelPricing adds j to the "model_pricing" field. +func (m *GroupMutation) AppendModelPricing(j jsontext.Value) { + m.appendmodel_pricing = append(m.appendmodel_pricing, j...) } // AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation. -func (m *GroupMutation) AppendedModelPricing() (json.RawMessage, bool) { +func (m *GroupMutation) AppendedModelPricing() (jsontext.Value, bool) { if len(m.appendmodel_pricing) == 0 { return nil, false } @@ -26281,7 +26515,7 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { m.SetLongContextPricingEnabled(v) return nil case group.FieldModelPricing: - v, ok := value.(json.RawMessage) + v, ok := value.(jsontext.Value) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } @@ -42917,8 +43151,8 @@ type UsageCleanupTaskMutation struct { created_at *time.Time updated_at *time.Time status *string - filters *json.RawMessage - appendfilters json.RawMessage + filters *jsontext.Value + appendfilters jsontext.Value created_by *int64 addcreated_by *int64 deleted_rows *int64 @@ -43142,13 +43376,13 @@ func (m *UsageCleanupTaskMutation) ResetStatus() { } // SetFilters sets the "filters" field. -func (m *UsageCleanupTaskMutation) SetFilters(jm json.RawMessage) { - m.filters = &jm +func (m *UsageCleanupTaskMutation) SetFilters(j jsontext.Value) { + m.filters = &j m.appendfilters = nil } // Filters returns the value of the "filters" field in the mutation. -func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) { +func (m *UsageCleanupTaskMutation) Filters() (r jsontext.Value, exists bool) { v := m.filters if v == nil { return @@ -43159,7 +43393,7 @@ func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) { // OldFilters returns the old "filters" field's value of the UsageCleanupTask entity. // If the UsageCleanupTask object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMessage, err error) { +func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v jsontext.Value, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldFilters is only allowed on UpdateOne operations") } @@ -43173,13 +43407,13 @@ func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMe return oldValue.Filters, nil } -// AppendFilters adds jm to the "filters" field. -func (m *UsageCleanupTaskMutation) AppendFilters(jm json.RawMessage) { - m.appendfilters = append(m.appendfilters, jm...) +// AppendFilters adds j to the "filters" field. +func (m *UsageCleanupTaskMutation) AppendFilters(j jsontext.Value) { + m.appendfilters = append(m.appendfilters, j...) } // AppendedFilters returns the list of values that were appended to the "filters" field in this mutation. -func (m *UsageCleanupTaskMutation) AppendedFilters() (json.RawMessage, bool) { +func (m *UsageCleanupTaskMutation) AppendedFilters() (jsontext.Value, bool) { if len(m.appendfilters) == 0 { return nil, false } @@ -43730,7 +43964,7 @@ func (m *UsageCleanupTaskMutation) SetField(name string, value ent.Value) error m.SetStatus(v) return nil case usagecleanuptask.FieldFilters: - v, ok := value.(json.RawMessage) + v, ok := value.(jsontext.Value) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 4be8d374d6..03a6894e0d 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -635,36 +635,28 @@ func init() { return nil } }() + // channelmonitorDescCheckMode is the schema descriptor for check_mode field. + channelmonitorDescCheckMode := channelmonitorFields[2].Descriptor() + // channelmonitor.DefaultCheckMode holds the default value on creation for the check_mode field. + channelmonitor.DefaultCheckMode = channelmonitorDescCheckMode.Default.(string) + // channelmonitor.CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save. + channelmonitor.CheckModeValidator = channelmonitorDescCheckMode.Validators[0].(func(string) error) // channelmonitorDescAPIMode is the schema descriptor for api_mode field. - channelmonitorDescAPIMode := channelmonitorFields[2].Descriptor() + channelmonitorDescAPIMode := channelmonitorFields[4].Descriptor() // channelmonitor.DefaultAPIMode holds the default value on creation for the api_mode field. channelmonitor.DefaultAPIMode = channelmonitorDescAPIMode.Default.(string) // channelmonitor.APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save. channelmonitor.APIModeValidator = channelmonitorDescAPIMode.Validators[0].(func(string) error) // channelmonitorDescEndpoint is the schema descriptor for endpoint field. - channelmonitorDescEndpoint := channelmonitorFields[3].Descriptor() + channelmonitorDescEndpoint := channelmonitorFields[5].Descriptor() // channelmonitor.EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save. - channelmonitor.EndpointValidator = func() func(string) error { - validators := channelmonitorDescEndpoint.Validators - fns := [...]func(string) error{ - validators[0].(func(string) error), - validators[1].(func(string) error), - } - return func(endpoint string) error { - for _, fn := range fns { - if err := fn(endpoint); err != nil { - return err - } - } - return nil - } - }() + channelmonitor.EndpointValidator = channelmonitorDescEndpoint.Validators[0].(func(string) error) // channelmonitorDescAPIKeyEncrypted is the schema descriptor for api_key_encrypted field. - channelmonitorDescAPIKeyEncrypted := channelmonitorFields[4].Descriptor() + channelmonitorDescAPIKeyEncrypted := channelmonitorFields[6].Descriptor() // channelmonitor.APIKeyEncryptedValidator is a validator for the "api_key_encrypted" field. It is called by the builders before save. channelmonitor.APIKeyEncryptedValidator = channelmonitorDescAPIKeyEncrypted.Validators[0].(func(string) error) // channelmonitorDescPrimaryModel is the schema descriptor for primary_model field. - channelmonitorDescPrimaryModel := channelmonitorFields[5].Descriptor() + channelmonitorDescPrimaryModel := channelmonitorFields[7].Descriptor() // channelmonitor.PrimaryModelValidator is a validator for the "primary_model" field. It is called by the builders before save. channelmonitor.PrimaryModelValidator = func() func(string) error { validators := channelmonitorDescPrimaryModel.Validators @@ -682,35 +674,35 @@ func init() { } }() // channelmonitorDescExtraModels is the schema descriptor for extra_models field. - channelmonitorDescExtraModels := channelmonitorFields[6].Descriptor() + channelmonitorDescExtraModels := channelmonitorFields[8].Descriptor() // channelmonitor.DefaultExtraModels holds the default value on creation for the extra_models field. channelmonitor.DefaultExtraModels = channelmonitorDescExtraModels.Default.([]string) // channelmonitorDescGroupName is the schema descriptor for group_name field. - channelmonitorDescGroupName := channelmonitorFields[7].Descriptor() + channelmonitorDescGroupName := channelmonitorFields[9].Descriptor() // channelmonitor.DefaultGroupName holds the default value on creation for the group_name field. channelmonitor.DefaultGroupName = channelmonitorDescGroupName.Default.(string) // channelmonitor.GroupNameValidator is a validator for the "group_name" field. It is called by the builders before save. channelmonitor.GroupNameValidator = channelmonitorDescGroupName.Validators[0].(func(string) error) // channelmonitorDescEnabled is the schema descriptor for enabled field. - channelmonitorDescEnabled := channelmonitorFields[8].Descriptor() + channelmonitorDescEnabled := channelmonitorFields[10].Descriptor() // channelmonitor.DefaultEnabled holds the default value on creation for the enabled field. channelmonitor.DefaultEnabled = channelmonitorDescEnabled.Default.(bool) // channelmonitorDescIntervalSeconds is the schema descriptor for interval_seconds field. - channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor() + channelmonitorDescIntervalSeconds := channelmonitorFields[11].Descriptor() // channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save. channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error) // channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field. - channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor() + channelmonitorDescJitterSeconds := channelmonitorFields[12].Descriptor() // channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field. channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int) // channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save. channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error) // channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field. - channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor() + channelmonitorDescExtraHeaders := channelmonitorFields[16].Descriptor() // channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field. channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string) // channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field. - channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor() + channelmonitorDescBodyOverrideMode := channelmonitorFields[17].Descriptor() // channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field. channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string) // channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save. @@ -808,7 +800,7 @@ func init() { // channelmonitorhistory.MessageValidator is a validator for the "message" field. It is called by the builders before save. channelmonitorhistory.MessageValidator = channelmonitorhistoryDescMessage.Validators[0].(func(string) error) // channelmonitorhistoryDescCheckedAt is the schema descriptor for checked_at field. - channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[6].Descriptor() + channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[7].Descriptor() // channelmonitorhistory.DefaultCheckedAt holds the default value on creation for the checked_at field. channelmonitorhistory.DefaultCheckedAt = channelmonitorhistoryDescCheckedAt.Default.(func() time.Time) channelmonitorrequesttemplateMixin := schema.ChannelMonitorRequestTemplate{}.Mixin() diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index cb62079316..e14416b765 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -35,15 +35,32 @@ func (ChannelMonitor) Fields() []ent.Field { NotEmpty(). MaxLen(100), field.Enum("provider"). - Values("openai", "anthropic", "gemini", "grok"), + Values("openai", "anthropic", "gemini", "grok", + "antigravity", "kimi", "zhipu", "deepseek"), + // check_mode: 'probe' | 'quota' | 'quota_probe' + // probe - LLM 探活(默认,原有行为) + // quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空) + // quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行) + // antigravity 无探活 adapter,仅允许 quota。 + field.String("check_mode"). + Default("probe"). + MaxLen(32). + Comment("probe = LLM probe (default); quota = account usage only; quota_probe = both"), + // account_id: 配额模式的数据源账号(复用账号侧用量服务,不直接对接上游)。 + // 普通字段而非 edge(FK 由 SQL 迁移管理);账号删除时数据库置空, + // 监控保留并报「账号未关联」。 + field.Int64("account_id"). + Optional(). + Nillable(), field.String("api_mode"). Default("chat_completions"). MaxLen(32). Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"), + // endpoint: 探活模式必填(service 层校验);quota 模式存空串 + // (列保持 NOT NULL,去掉 NotEmpty 校验器即可)。 field.String("endpoint"). - NotEmpty(). MaxLen(500). - Comment("Provider base origin, e.g. https://api.openai.com"), + Comment("Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors"), field.String("api_key_encrypted"). NotEmpty(). Sensitive(). @@ -115,5 +132,6 @@ func (ChannelMonitor) Indexes() []ent.Index { index.Fields("provider", "api_mode"), index.Fields("group_name"), index.Fields("template_id"), + index.Fields("account_id"), } } diff --git a/backend/ent/schema/channel_monitor_history.go b/backend/ent/schema/channel_monitor_history.go index 4366e79a67..c2ec4dc25c 100644 --- a/backend/ent/schema/channel_monitor_history.go +++ b/backend/ent/schema/channel_monitor_history.go @@ -3,6 +3,8 @@ package schema import ( "time" + "github.com/Wei-Shaw/sub2api/internal/domain" + "entgo.io/ent" "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema" @@ -43,6 +45,10 @@ func (ChannelMonitorHistory) Fields() []ent.Field { Optional(). Default(""). MaxLen(500), + // quota: 配额模式(check_mode = quota / quota_probe)检测时附带的 + // 归一化配额快照(domain.MonitorQuotaSnapshot,JSONB);探活模式为 NULL。 + field.JSON("quota", &domain.MonitorQuotaSnapshot{}). + Optional(), field.Time("checked_at"). Default(time.Now), } diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index cf7fe05158..332a01007c 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -39,7 +39,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field { NotEmpty(). MaxLen(100), field.Enum("provider"). - Values("openai", "anthropic", "gemini", "grok"), + Values("openai", "anthropic", "gemini", "grok", + "antigravity", "kimi", "zhipu", "deepseek"), field.String("api_mode"). Default("chat_completions"). MaxLen(32). diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index a0b5598600..123d7c0bfa 100644 --- a/backend/ent/schema/user_platform_quota.go +++ b/backend/ent/schema/user_platform_quota.go @@ -41,7 +41,8 @@ func (UserPlatformQuota) Fields() []ent.Field { // 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms; // 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。 switch s { - case "anthropic", "openai", "gemini", "antigravity", "grok": + case "anthropic", "openai", "gemini", "antigravity", "grok", + "kimi", "zhipu", "deepseek": return nil default: return fmt.Errorf("platform %q is not allowed", s) diff --git a/backend/ent/usagecleanuptask.go b/backend/ent/usagecleanuptask.go index e3a17b5aed..b4dbf0fd34 100644 --- a/backend/ent/usagecleanuptask.go +++ b/backend/ent/usagecleanuptask.go @@ -4,6 +4,7 @@ package ent import ( "encoding/json" + "encoding/json/jsontext" "fmt" "strings" "time" @@ -25,7 +26,7 @@ type UsageCleanupTask struct { // Status holds the value of the "status" field. Status string `json:"status,omitempty"` // Filters holds the value of the "filters" field. - Filters json.RawMessage `json:"filters,omitempty"` + Filters jsontext.Value `json:"filters,omitempty"` // CreatedBy holds the value of the "created_by" field. CreatedBy int64 `json:"created_by,omitempty"` // DeletedRows holds the value of the "deleted_rows" field. diff --git a/backend/ent/usagecleanuptask_create.go b/backend/ent/usagecleanuptask_create.go index 0b1dcff55e..b6dbcb3cc8 100644 --- a/backend/ent/usagecleanuptask_create.go +++ b/backend/ent/usagecleanuptask_create.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -58,7 +58,7 @@ func (_c *UsageCleanupTaskCreate) SetStatus(v string) *UsageCleanupTaskCreate { } // SetFilters sets the "filters" field. -func (_c *UsageCleanupTaskCreate) SetFilters(v json.RawMessage) *UsageCleanupTaskCreate { +func (_c *UsageCleanupTaskCreate) SetFilters(v jsontext.Value) *UsageCleanupTaskCreate { _c.mutation.SetFilters(v) return _c } @@ -375,7 +375,7 @@ func (u *UsageCleanupTaskUpsert) UpdateStatus() *UsageCleanupTaskUpsert { } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsert) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsert { +func (u *UsageCleanupTaskUpsert) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsert { u.Set(usagecleanuptask.FieldFilters, v) return u } @@ -592,7 +592,7 @@ func (u *UsageCleanupTaskUpsertOne) UpdateStatus() *UsageCleanupTaskUpsertOne { } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsertOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertOne { +func (u *UsageCleanupTaskUpsertOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertOne { return u.Update(func(s *UsageCleanupTaskUpsert) { s.SetFilters(v) }) @@ -999,7 +999,7 @@ func (u *UsageCleanupTaskUpsertBulk) UpdateStatus() *UsageCleanupTaskUpsertBulk } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsertBulk) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertBulk { +func (u *UsageCleanupTaskUpsertBulk) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertBulk { return u.Update(func(s *UsageCleanupTaskUpsert) { s.SetFilters(v) }) diff --git a/backend/ent/usagecleanuptask_update.go b/backend/ent/usagecleanuptask_update.go index 604202c679..d233258bbe 100644 --- a/backend/ent/usagecleanuptask_update.go +++ b/backend/ent/usagecleanuptask_update.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -51,13 +51,13 @@ func (_u *UsageCleanupTaskUpdate) SetNillableStatus(v *string) *UsageCleanupTask } // SetFilters sets the "filters" field. -func (_u *UsageCleanupTaskUpdate) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdate { +func (_u *UsageCleanupTaskUpdate) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdate { _u.mutation.SetFilters(v) return _u } // AppendFilters appends value to the "filters" field. -func (_u *UsageCleanupTaskUpdate) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdate { +func (_u *UsageCleanupTaskUpdate) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdate { _u.mutation.AppendFilters(v) return _u } @@ -374,13 +374,13 @@ func (_u *UsageCleanupTaskUpdateOne) SetNillableStatus(v *string) *UsageCleanupT } // SetFilters sets the "filters" field. -func (_u *UsageCleanupTaskUpdateOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne { +func (_u *UsageCleanupTaskUpdateOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne { _u.mutation.SetFilters(v) return _u } // AppendFilters appends value to the "filters" field. -func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne { +func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne { _u.mutation.AppendFilters(v) return _u } diff --git a/backend/go.mod b/backend/go.mod index d6f1c10ed6..b86368117f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.5 +go 1.27.0 require ( entgo.io/ent v0.14.5 @@ -24,6 +24,8 @@ require ( github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.8.0 github.com/imroc/req/v3 v3.59.0 github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 @@ -54,6 +56,8 @@ require ( golang.org/x/net v0.56.0 golang.org/x/sync v0.21.0 golang.org/x/term v0.44.0 + google.golang.org/grpc v1.82.1 + google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -121,12 +125,14 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect @@ -149,6 +155,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect @@ -187,10 +194,9 @@ require ( github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -200,8 +206,7 @@ require ( golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.47.0 // indirect - google.golang.org/grpc v1.75.1 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/ini.v1 v1.67.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index cdea1a5b91..cbb257f12f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -120,6 +120,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -176,6 +178,7 @@ github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -232,6 +235,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -250,8 +255,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -262,6 +265,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= @@ -269,6 +276,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo= @@ -281,6 +290,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -307,13 +318,15 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -350,8 +363,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -386,8 +399,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -423,8 +434,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -439,6 +448,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -507,18 +517,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -608,6 +620,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -615,6 +629,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -678,29 +695,31 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0ed2b17213..8c856cbf61 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,7 +32,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( @@ -61,6 +61,10 @@ const ( // 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。 const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024 +// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。 +// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。 +const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024 + type Config struct { Server ServerConfig `mapstructure:"server"` Log LogConfig `mapstructure:"log"` @@ -99,6 +103,18 @@ type Config struct { Idempotency IdempotencyConfig `mapstructure:"idempotency"` BatchImage BatchImageConfig `mapstructure:"batch_image"` ImageStorage ImageStorageConfig `mapstructure:"image_storage"` + Plugins PluginConfig `mapstructure:"plugins"` +} + +// PluginConfig 控制管理员手动上传的本地进程插件。 +// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。 +type PluginConfig struct { + DataDir string `mapstructure:"data_dir"` + AllowUnsigned bool `mapstructure:"allow_unsigned"` + TrustedPublishers map[string]string `mapstructure:"trusted_publishers"` + MaxUploadBytes int64 `mapstructure:"max_upload_bytes"` + MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"` + StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"` } type LogConfig struct { @@ -830,6 +846,53 @@ type ProxyFallbackConfig struct { type ProxyProbeConfig struct { InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 + // URLs 按优先级排列的自定义探测 URL 列表。 + // 留空时使用内置默认列表(ip-api → ipify)。 + // 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。 + URLs []ProbeURLConfig `mapstructure:"urls"` +} + +// ProbeURLConfig 描述一个探测端点及其响应解析方式。 +type ProbeURLConfig struct { + URL string `mapstructure:"url"` + Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace" +} + +func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) { + if len(targets) == 0 { + return nil, nil + } + + normalized := make([]ProbeURLConfig, 0, len(targets)) + for i, target := range targets { + rawURL := strings.TrimSpace(target.URL) + parser := strings.ToLower(strings.TrimSpace(target.Parser)) + if rawURL == "" { + return nil, fmt.Errorf("entry %d: url is required", i) + } + if parser == "" { + return nil, fmt.Errorf("entry %d: parser is required", i) + } + switch parser { + case "ip-api", "ipify", "chatgpt-trace": + default: + return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser) + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("entry %d: url scheme must be http or https", i) + } + + normalized = append(normalized, ProbeURLConfig{ + URL: rawURL, + Parser: parser, + }) + } + return normalized, nil } type BillingConfig struct { @@ -887,6 +950,9 @@ type GatewayConfig struct { // OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时 // OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。 OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"` + // GrokResponseHeaderTimeout bounds the pre-first-byte wait for xAI/Grok. + // A zero value uses the provider-safe default instead of the generic gateway timeout. + GrokResponseHeaderTimeout int `mapstructure:"grok_response_header_timeout"` // OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。 OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"` // OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。 @@ -898,6 +964,8 @@ type GatewayConfig struct { TextMaxBodySize int64 `mapstructure:"text_max_body_size"` // 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大 UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"` + // 上游模型列表响应体读取上限(字节) + ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"` // 代理探测响应体读取上限(字节) ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"` // Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销) @@ -1025,6 +1093,10 @@ type GatewayConfig struct { // Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings. Grok GatewayGrokConfig `mapstructure:"grok"` + + // CNProviders: 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。 + // 仅作用于 payg(按量付费)账号:周期探测余额,低于阈值则临时停调。 + CNProviders GatewayCNProvidersConfig `mapstructure:"cn_providers"` } // GatewayGrokConfig holds Grok-specific gateway scheduling knobs. @@ -1057,6 +1129,18 @@ type GatewayGrokConfig struct { FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"` } +// GatewayCNProvidersConfig 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。 +// +// 仅作用于 payg(按量付费)账号(kimi/deepseek 有公开余额端点;zhipu 无,仅靠响应式 429/402)。 +// - balance_check_enabled: 是否启用周期余额检测(默认 true) +// - balance_threshold: 余额低于此值(账户货币单位,默认 0.5)触发临时停调 +// - balance_check_interval_minutes: 余额检测周期(分钟,默认 10) +type GatewayCNProvidersConfig struct { + BalanceCheckEnabled bool `mapstructure:"balance_check_enabled"` + BalanceThreshold float64 `mapstructure:"balance_threshold"` + BalanceCheckIntervalMinutes int `mapstructure:"balance_check_interval_minutes"` +} + type GatewayLiveConfig struct { // MaxSessionDurationSeconds 是 Live 会话的硬上限。 MaxSessionDurationSeconds int `mapstructure:"max_session_duration_seconds"` @@ -1703,6 +1787,10 @@ func load(allowMissingJWTSecret bool) (*Config, error) { // 环境变量支持 viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + if tz, ok := os.LookupEnv("TZ"); ok && strings.TrimSpace(tz) != "" { + // AutomaticEnv 会先把 timezone 映射到 TIMEZONE;显式 Set 保证标准 TZ 变量优先。 + viper.Set("timezone", strings.TrimSpace(tz)) + } if err := viper.BindEnv("server.enable_server_timing", "ENABLE_SERVER_TIMING"); err != nil { return nil, fmt.Errorf("bind ENABLE_SERVER_TIMING: %w", err) } @@ -2199,6 +2287,14 @@ func setDefaults() { viper.SetDefault("pricing.update_interval_hours", 24) viper.SetDefault("pricing.hash_check_interval_minutes", 10) + // 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。 + viper.SetDefault("plugins.data_dir", "") + viper.SetDefault("plugins.allow_unsigned", false) + viper.SetDefault("plugins.trusted_publishers", map[string]string{}) + viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024)) + viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024)) + viper.SetDefault("plugins.start_timeout_seconds", 15) + // Timezone (default to Asia/Shanghai for Chinese users) viper.SetDefault("timezone", "Asia/Shanghai") @@ -2260,6 +2356,7 @@ func setDefaults() { // Gateway viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久 viper.SetDefault("gateway.openai_response_header_timeout", 0) + viper.SetDefault("gateway.grok_response_header_timeout", 120) viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0) viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0) viper.SetDefault("gateway.log_upstream_error_body", true) @@ -2351,6 +2448,10 @@ func setDefaults() { viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95) viper.SetDefault("gateway.grok.free_quota_window_hours", 24) viper.SetDefault("gateway.grok.free_quota_stats_cache_seconds", 60) + // 国产供应商余额检测(kimi/deepseek payg;zhipu 无余额端点,仅靠响应式 429/402)。 + viper.SetDefault("gateway.cn_providers.balance_check_enabled", true) + viper.SetDefault("gateway.cn_providers.balance_threshold", 0.5) + viper.SetDefault("gateway.cn_providers.balance_check_interval_minutes", 10) viper.SetDefault("gateway.image_concurrency.enabled", false) viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0) viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject) @@ -2361,6 +2462,7 @@ func setDefaults() { viper.SetDefault("gateway.max_body_size", int64(256*1024*1024)) viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024)) viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes) + viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes) viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024)) viper.SetDefault("gateway.gemini_debug_response_headers", false) viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy) @@ -2545,6 +2647,20 @@ func (c *Config) Validate() error { } c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) + proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs) + if err != nil { + return fmt.Errorf("security.proxy_probe.urls: %w", err) + } + c.Security.ProxyProbe.URLs = proxyProbeURLs + if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 { + return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824") + } + if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 { + return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648") + } + if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 { + return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120") + } if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } @@ -3156,6 +3272,9 @@ func (c *Config) Validate() error { if c.Gateway.UpstreamResponseReadMaxBytes <= 0 { return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive") } + if c.Gateway.ModelsListReadMaxBytes <= 0 { + return fmt.Errorf("gateway.models_list_read_max_bytes must be positive") + } if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 { return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive") } @@ -3165,6 +3284,9 @@ func (c *Config) Validate() error { if c.Gateway.OpenAIResponseHeaderTimeout < 0 { return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative") } + if c.Gateway.GrokResponseHeaderTimeout < 0 || c.Gateway.GrokResponseHeaderTimeout > 1800 { + return fmt.Errorf("gateway.grok_response_header_timeout must be between 0-1800 seconds") + } if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 || (c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) { return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 718913a044..a8fbba0977 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -23,6 +23,45 @@ func resetViperWithJWTSecret(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) } +func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes) +} + +func TestLoadTimezonePrecedence(t *testing.T) { + tests := []struct { + name string + fileTimezone string + timezoneEnv string + tzEnv string + want string + }{ + {name: "default", want: "Asia/Shanghai"}, + {name: "config_file", fileTimezone: "Europe/London", want: "Europe/London"}, + {name: "timezone_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", want: "UTC"}, + {name: "tz_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", tzEnv: "America/New_York", want: "America/New_York"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("TIMEZONE", tt.timezoneEnv) + t.Setenv("TZ", tt.tzEnv) + if tt.fileTimezone != "" { + configFile := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(configFile, []byte("timezone: "+tt.fileTimezone+"\n"), 0o600)) + t.Setenv("CONFIG_FILE", configFile) + } + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, tt.want, cfg.Timezone) + }) + } +} + func TestLoadServerTimingConfig(t *testing.T) { t.Run("disabled by default", func(t *testing.T) { resetViperWithJWTSecret(t) @@ -521,6 +560,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) { require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds) } +func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true") + + cfg, err := Load() + require.NoError(t, err) + require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP) +} + func TestLoadDefaultOpenAICompactModel(t *testing.T) { resetViperWithJWTSecret(t) @@ -1762,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 }, wantErr: "gateway.text_max_body_size", }, + { + name: "gateway models list read limit", + mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 }, + wantErr: "gateway.models_list_read_max_bytes", + }, { name: "gateway response header timeout", mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 }, diff --git a/backend/internal/config/env_reachability_test.go b/backend/internal/config/env_reachability_test.go index 8141a2ec3d..cfe39587ed 100644 --- a/backend/internal/config/env_reachability_test.go +++ b/backend/internal/config/env_reachability_test.go @@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // is out of scope here — such settings need a config file either way. continue } + if ft.Kind() == reflect.Slice { + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + // AutomaticEnv exposes one string value. Viper's string-to-slice + // hook can populate scalar slices, but it cannot decode a string + // into []struct. Registering a default would turn silent ignore + // into a startup unmarshal error, so structured slices remain + // config-file-only just like maps. + continue + } + } out[strings.ToLower(key)] = ft.String() } } @@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // were lost, silently disabling async image tasks for env-driven deployments. // // When this fails, register a zero-valued default in setEnvReachableDefaults -// for each reported key. +// for each reported scalar key. Maps and slices of structs are config-file-only. func TestConfigKeysAreEnvReachable(t *testing.T) { bound := map[string]string{} collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) diff --git a/backend/internal/config/proxy_probe_config_test.go b/backend/internal/config/proxy_probe_config_test.go new file mode 100644 index 0000000000..af5b97209e --- /dev/null +++ b/backend/internal/config/proxy_probe_config_test.go @@ -0,0 +1,47 @@ +//go:build unit + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeProxyProbeURLs(t *testing.T) { + t.Parallel() + + got, err := normalizeProxyProbeURLs([]ProbeURLConfig{ + {URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }) + require.NoError(t, err) + require.Equal(t, []ProbeURLConfig{ + {URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }, got) +} + +func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target ProbeURLConfig + wantErr string + }{ + {name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"}, + {name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"}, + {name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"}, + {name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"}, + {name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/backend/internal/domain/channel_monitor_quota.go b/backend/internal/domain/channel_monitor_quota.go new file mode 100644 index 0000000000..1b93cd3942 --- /dev/null +++ b/backend/internal/domain/channel_monitor_quota.go @@ -0,0 +1,68 @@ +package domain + +import "time" + +// 渠道监控「配额模式」的归一化配额快照类型。 +// +// 配额模式监控不直接对接上游,而是关联一个已有账号,复用账号侧的用量服务 +// (AccountUsageService / CNProviderQuotaService / CNProviderBalanceService), +// 把各平台形态各异的用量数据归一成 MonitorQuotaSnapshot,随检测历史持久化 +// 到 channel_monitor_histories.quota(JSONB),供管理端与用户端渲染。 +// +// 类型放在 domain 包是因为 ent schema(internal/domain 的下游)需要引用它做 +// field.JSON 序列化;service 不能被 ent import(会造成循环依赖)。 + +// MonitorQuotaTier 单个用量窗口的快照。 +// +// Window 取值约定(与前端 monitorCommon.quota.windows.* 标签一一对应): +// - "5h" 5 小时滚动窗口(Claude/Codex/Kimi/Zhipu coding plan) +// - "7d" 7 天窗口(Claude/Codex) +// - "7d-sonnet" Claude 7 天 Sonnet 独立额度 +// - "7d-fable" Claude 7 天 Fable 独立额度 +// - "weekly" 周窗口(Kimi/Zhipu coding plan) +// - "daily" 日窗口(Gemini 日配额 / Grok 日请求) +// - "30d" 30 天窗口(Grok 月度) +// - "total" 无窗口语义的总量额度(Antigravity per-model 等) +// +// 同一 Window 可能出现多条(Gemini 多档日配额、Antigravity per-model、 +// Grok requests/tokens),用 Label 区分:Label 是机器 token(requests/tokens/ +// shared/pro/flash 或模型名),前端已知 token 走 i18n,未知原样展示。 +type MonitorQuotaTier struct { + Window string `json:"window"` + Label string `json:"label,omitempty"` + UsedPercent float64 `json:"used_percent"` // 0-100+;仅有绝对值时按 used/limit 计算 + Used float64 `json:"used,omitempty"` + Limit float64 `json:"limit,omitempty"` + ResetAt string `json:"reset_at,omitempty"` // RFC3339;未知时留空 +} + +// MonitorQuotaSnapshot 一次配额查询的完整快照。 +// +// Source 取值: +// - "usage" 海外平台(AccountUsageService.GetUsage) +// - "cn_quota" 国产 Coding Plan(CNProviderQuotaService.QueryUsage) +// - "cn_balance" 国产按量付费余额(CNProviderBalanceService.QueryBalance) +type MonitorQuotaSnapshot struct { + Source string `json:"source"` + Success bool `json:"success"` + Tiers []MonitorQuotaTier `json:"tiers,omitempty"` + Balance *float64 `json:"balance,omitempty"` // cn_balance 主余额 + Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD) + Currency string `json:"currency,omitempty"` // 主余额币种 + PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level) + // BalanceLow 余额低于阈值或账号被上游标记不可用(仅 cn_balance 来源)。 + // 抓取器按 Gateway.CNProviders.BalanceThreshold 判定,口径与账号停调 + // (CNProviderBalanceCheckService.checkOne)一致:任一币种达标即健康。 + BalanceLow bool `json:"balance_low,omitempty"` + // CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误), + // 检测状态据此推导 failed 而非 error。 + CredentialInvalid bool `json:"credential_invalid,omitempty"` + Error string `json:"error,omitempty"` // Success=false 时的错误摘要 + FetchedAt time.Time `json:"fetched_at"` +} + +// MonitorBalance 单币种余额条目。 +type MonitorBalance struct { + Currency string `json:"currency"` + Balance float64 `json:"balance"` +} diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 148c1cd5d3..1ed90ee940 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -23,7 +23,28 @@ const ( PlatformGemini = "gemini" PlatformAntigravity = "antigravity" PlatformGrok = "grok" - PlatformComposite = "composite" + // 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。 + PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) + PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) + PlatformDeepseek = "deepseek" // DeepSeek + PlatformComposite = "composite" +) + +// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。 +// 存储于 credentials["account_mode"],决定 base_url 预设与额度监控方式。 +const ( + AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却 + AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly) +) + +// API protocol constants 国产供应商的上游 API 协议维度。存储于 +// credentials["api_protocol"],与 account_mode 正交:协议决定转发端点与格式, +// 模式决定额度监控方式。同协议请求零转换直通;跨协议组合才走转换链。 +const ( + APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认) + APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code) + APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex) + APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点 ) // Account type constants @@ -84,11 +105,11 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射 "claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型 "claude-sonnet-4-6": "claude-sonnet-4-6", - "claude-sonnet-4-5": "claude-sonnet-4-5", - "claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking", + "claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传 + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名 // Claude 详细版本 ID 映射 "claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型 - "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型 // Claude Haiku → Sonnet(无 Haiku 支持) "claude-haiku-4-5": "claude-sonnet-4-6", "claude-haiku-4-5-20251001": "claude-sonnet-4-6", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index e847e22f30..acac038ef2 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -43,6 +43,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "claude-sonnet-4-5": "claude-sonnet-4-5", + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", + } + for model, want := range cases { + if got := DefaultAntigravityModelMapping[model]; got != want { + t.Fatalf("expected model %q to map to %q, got %q", model, want, got) + } + } +} + func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 30fc6b9828..599a04a34e 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1025,7 +1025,8 @@ func (h *AccountHandler) Update(c *gin.Context) { // 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失, // 网关会按"现状即证据"默认走 Responses。 func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) { - if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey { + if account == nil || account.Type != service.AccountTypeAPIKey || + (account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) { return } if h.accountTestService == nil { @@ -2613,8 +2614,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { // Handle Gemini accounts if account.IsGemini() { - // For OAuth accounts: return default Gemini models + // Consumer Google One OAuth still uses the legacy Gemini CLI / Code + // Assist channel. Do not advertise newer 3.x or image models that the + // channel cannot serve. if account.IsOAuth() { + if account.IsGeminiGoogleOne() { + response.Success(c, geminicli.GoogleOneModels) + return + } response.Success(c, geminicli.DefaultModels) return } @@ -2767,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { return } - models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account) + catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account) if err != nil { var syncErr *service.UpstreamModelSyncError if errors.As(err, &syncErr) { switch syncErr.Kind { case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: response.BadRequest(c, syncErr.SafeMessage()) + case service.UpstreamModelSyncErrorInternal: + response.InternalError(c, syncErr.SafeMessage()) default: slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind) response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) @@ -2786,29 +2795,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { return } - response.Success(c, gin.H{"models": models}) + response.Success(c, catalog) } // SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed). // POST /api/v1/admin/accounts/models/sync-upstream-preview func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { var req struct { - Platform string `json:"platform" binding:"required"` - Type string `json:"type" binding:"required"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key" binding:"required"` + Platform string `json:"platform" binding:"required"` + Type string `json:"type" binding:"required"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key" binding:"required"` + ModelMapping map[string]string `json:"model_mapping"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } + modelMapping := make(map[string]any, len(req.ModelMapping)) + for sourceModel, upstreamModel := range req.ModelMapping { + modelMapping[sourceModel] = upstreamModel + } tempAccount := &service.Account{ Platform: req.Platform, Type: req.Type, Credentials: map[string]any{ - "api_key": req.APIKey, - "base_url": req.BaseURL, + "api_key": req.APIKey, + "base_url": req.BaseURL, + "model_mapping": modelMapping, }, } @@ -2817,13 +2832,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { return } - models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount) + catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount) if err != nil { var syncErr *service.UpstreamModelSyncError if errors.As(err, &syncErr) { switch syncErr.Kind { case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: response.BadRequest(c, syncErr.SafeMessage()) + case service.UpstreamModelSyncErrorInternal: + response.InternalError(c, syncErr.SafeMessage()) default: slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind) response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) @@ -2836,7 +2853,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { return } - response.Success(c, gin.H{"models": models}) + response.Success(c, catalog) } // SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index 880cb8c3ea..28dc6d1492 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine { } type syncUpstreamHTTPUpstream struct { - resp *http.Response - err error + resp *http.Response + responses []*http.Response + err error } func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { if u.err != nil { return nil, u.err } + if len(u.responses) > 0 { + resp := u.responses[0] + u.responses = u.responses[1:] + return resp, nil + } return u.resp, nil } @@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi ) handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil) router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels) + router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview) return router } @@ -287,6 +294,42 @@ func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels( }, ids, "影子可用模型由 model_mapping 派生(非写死)") } +func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 45, + Name: "google-one", + Platform: service.PlatformGemini, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Credentials: map[string]any{ + "oauth_type": "google_one", + }, + }, + } + router := setupAvailableModelsRouter(svc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + ids := make([]string, 0, len(resp.Data)) + for _, model := range resp.Data { + ids = append(ids, model.ID) + } + require.ElementsMatch(t, []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"}, ids) + require.NotContains(t, ids, "gemini-3.5-flash") + require.NotContains(t, ids, "gemini-2.5-flash-image") +} + func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) { svc := &availableModelsAdminService{ stubAdminService: newStubAdminService(), @@ -311,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin require.Contains(t, rec.Body.String(), "No OpenAI API key is available") } +func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, Status: service.StatusActive, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }, + } + upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"custom-thinking-model", + "reasoning":true, + "default_reasoning_level":"high", + "supported_reasoning_levels":["low","high"], + "input_modalities":["text","image"], + "context_window":256000 + }]}`)), + }} + router := setupSyncUpstreamModelsRouter(svc, upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data service.UpstreamModelCatalog `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models) + metadata := resp.Data.Metadata["custom-thinking-model"] + require.NotNil(t, metadata.Reasoning) + require.True(t, *metadata.Reasoning) + require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) +} + +// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。 +func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) { + upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "configured-provider": { + "api": "https://provider.example/v1", + "models": { + "glm-5.3": { + "id": "glm-5.3", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","high"]}], + "modalities": {"input":["text"],"output":["text"]}, + "limit": {"context":1000000,"output":131072} + } + } + } + }`)), + }, + }} + router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/models/sync-upstream-preview", + strings.NewReader(`{ + "platform":"openai", + "type":"apikey", + "base_url":"https://provider.example/v1", + "api_key":"key", + "model_mapping":{"public-glm":"glm-5.3"} + }`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data service.UpstreamModelCatalog `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"glm-5.3"}, resp.Data.Models) + require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels) +} + func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) { svc := &availableModelsAdminService{ stubAdminService: newStubAdminService(), @@ -341,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502") require.NotContains(t, rec.Body.String(), "SECRET_TOKEN") } + +// Scenario: 能力补全失败显示部分成功。 +func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 46, + Name: "opencode-id-only-model-list", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Credentials: map[string]any{ + "api_key": "opencode-key", + "base_url": "https://opencode.ai/zen/v1", + }, + }, + } + upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)), + }, + { + StatusCode: http.StatusBadGateway, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)), + }, + }} + router := setupSyncUpstreamModelsRouter(svc, upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data struct { + Models []string `json:"models"` + Warnings []struct { + Code string `json:"code"` + } `json:"warnings"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models) + require.Len(t, resp.Data.Warnings, 1) + require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code) +} diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index ade8f0c95c..c1b3507aef 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -57,29 +57,48 @@ type updateChannelRequest struct { } type channelModelPricingRequest struct { - Platform string `json:"platform" binding:"omitempty,max=50"` - Models []string `json:"models" binding:"required,min=1,max=100"` - BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` - InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` - OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` - CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` - CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` - ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` - ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` - PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` - Intervals []pricingIntervalRequest `json:"intervals"` + Platform string `json:"platform" binding:"omitempty,max=50"` + Models []string `json:"models" binding:"required,min=1,max=100"` + BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` + InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` + OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` + CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` + CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` + ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` + PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` + Intervals []pricingIntervalRequest `json:"intervals"` + TimePricing *channelTimePricingRequest `json:"time_pricing"` +} + +type channelTimePricingRequest struct { + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only"` + Periods []channelTimePricingPeriodRequest `json:"periods"` +} + +type channelTimePricingPeriodRequest struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Multiplier float64 `json:"multiplier"` } type pricingIntervalRequest struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"` + OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier" binding:"omitempty,gt=0"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier" binding:"omitempty,gt=0"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` } type accountStatsPricingRuleRequest struct { @@ -108,31 +127,50 @@ type channelResponse struct { } type channelModelPricingResponse struct { - ID int64 `json:"id"` - Platform string `json:"platform"` - Models []string `json:"models"` - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []pricingIntervalResponse `json:"intervals"` + ID int64 `json:"id"` + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []pricingIntervalResponse `json:"intervals"` + TimePricing *channelTimePricingResponse `json:"time_pricing"` +} + +type channelTimePricingResponse struct { + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only"` + Periods []channelTimePricingPeriodResponse `json:"periods"` +} + +type channelTimePricingPeriodResponse struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Multiplier float64 `json:"multiplier"` } type pricingIntervalResponse struct { - ID int64 `json:"id"` - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` + ID int64 `json:"id"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` } type accountStatsPricingRuleResponse struct { @@ -224,29 +262,55 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon OutputPrice: p.OutputPrice, CacheWritePrice: p.CacheWritePrice, CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, ImageInputPrice: p.ImageInputPrice, ImageOutputPrice: p.ImageOutputPrice, PerRequestPrice: p.PerRequestPrice, Intervals: intervals, + TimePricing: timePricingToResponse(p.TimePricing), + } +} + +func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricingResponse { + if value == nil { + return nil + } + periods := make([]channelTimePricingPeriodResponse, 0, len(value.Periods)) + for _, period := range value.Periods { + periods = append(periods, channelTimePricingPeriodResponse{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: period.Multiplier, + }) + } + return &channelTimePricingResponse{ + Timezone: value.Timezone, + WeekdaysOnly: value.WeekdaysOnly, + Periods: periods, } } func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse { return pricingIntervalResponse{ - ID: iv.ID, - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, - SortOrder: iv.SortOrder, + ID: iv.ID, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, + SortOrder: iv.SortOrder, } } -func pricingRequestToService(reqs []channelModelPricingRequest) []service.ChannelModelPricing { +func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMultipliers bool) []service.ChannelModelPricing { result := make([]service.ChannelModelPricing, 0, len(reqs)) for _, r := range reqs { billingMode := service.BillingMode(r.BillingMode) @@ -256,18 +320,34 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe platform := r.Platform intervals := make([]service.PricingInterval, 0, len(r.Intervals)) for _, iv := range r.Intervals { + var inputMultiplier, outputMultiplier, cacheWriteMultiplier, cacheReadMultiplier *float64 + if allowChannelMultipliers { + inputMultiplier = iv.InputMultiplier + outputMultiplier = iv.OutputMultiplier + cacheWriteMultiplier = iv.CacheWriteMultiplier + cacheReadMultiplier = iv.CacheReadMultiplier + } intervals = append(intervals, service.PricingInterval{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, - SortOrder: iv.SortOrder, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: inputMultiplier, + OutputMultiplier: outputMultiplier, + CacheWriteMultiplier: cacheWriteMultiplier, + CacheReadMultiplier: cacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, + SortOrder: iv.SortOrder, }) } + var fastMultiplier, flexMultiplier *float64 + if allowChannelMultipliers { + fastMultiplier = r.FastMultiplier + flexMultiplier = r.FlexMultiplier + } result = append(result, service.ChannelModelPricing{ Platform: platform, Models: r.Models, @@ -276,21 +356,43 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe OutputPrice: r.OutputPrice, CacheWritePrice: r.CacheWritePrice, CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, ImageInputPrice: r.ImageInputPrice, ImageOutputPrice: r.ImageOutputPrice, PerRequestPrice: r.PerRequestPrice, Intervals: intervals, + TimePricing: timePricingRequestToService(r.TimePricing), }) } return result } +func timePricingRequestToService(value *channelTimePricingRequest) *service.ChannelTimePricing { + if value == nil { + return nil + } + periods := make([]service.ChannelTimePricingPeriod, 0, len(value.Periods)) + for _, period := range value.Periods { + periods = append(periods, service.ChannelTimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: period.Multiplier, + }) + } + return &service.ChannelTimePricing{ + Timezone: value.Timezone, + WeekdaysOnly: value.WeekdaysOnly, + Periods: periods, + } +} + func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule { return service.AccountStatsPricingRule{ Name: r.Name, GroupIDs: r.GroupIDs, AccountIDs: r.AccountIDs, - Pricing: pricingRequestToService(r.Pricing), + Pricing: pricingRequestToService(r.Pricing, false), } } @@ -351,7 +453,7 @@ func (h *ChannelHandler) Create(c *gin.Context) { return } - pricing := pricingRequestToService(req.ModelPricing) + pricing := pricingRequestToService(req.ModelPricing, true) // Main model_pricing requires a platform; default to anthropic for backward compatibility. for i := range pricing { if pricing[i].Platform == "" { @@ -425,7 +527,7 @@ func (h *ChannelHandler) Update(c *gin.Context) { ApplyPricingToAccountStats: req.ApplyPricingToAccountStats, } if req.ModelPricing != nil { - pricing := pricingRequestToService(*req.ModelPricing) + pricing := pricingRequestToService(*req.ModelPricing, true) for i := range pricing { if pricing[i].Platform == "" { pricing[i].Platform = service.PlatformAnthropic @@ -512,9 +614,12 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { var platformToLiteLLMProvider = map[string]string{ service.PlatformAnthropic: "anthropic", service.PlatformOpenAI: "openai", - service.PlatformGemini: "google", + service.PlatformGemini: "gemini", service.PlatformAntigravity: "anthropic", service.PlatformGrok: "xai", + service.PlatformKimi: "moonshot", + service.PlatformZhipu: "zhipu", + service.PlatformDeepseek: "deepseek", } // SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表 diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index d05a1a6a3b..2da7e02d00 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -305,7 +305,7 @@ func TestPricingRequestToService_Defaults(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := pricingRequestToService([]channelModelPricingRequest{tt.req}) + result := pricingRequestToService([]channelModelPricingRequest{tt.req}, true) require.Len(t, result, 1) switch tt.wantField { case "BillingMode": @@ -332,7 +332,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) r := result[0] require.Equal(t, "openai", r.Platform) @@ -373,7 +373,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) require.Len(t, result[0].Intervals, 2) @@ -396,7 +396,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) { } func TestPricingRequestToService_EmptySlice(t *testing.T) { - result := pricingRequestToService([]channelModelPricingRequest{}) + result := pricingRequestToService([]channelModelPricingRequest{}, true) require.NotNil(t, result) require.Empty(t, result) } @@ -410,7 +410,7 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) r := result[0] require.Nil(t, r.InputPrice) @@ -421,6 +421,89 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) { require.Nil(t, r.PerRequestPrice) } +func TestPricingRequestToService_TimePricing(t *testing.T) { + req := channelModelPricingRequest{ + Models: []string{"gpt-5"}, + BillingMode: "token", + TimePricing: &channelTimePricingRequest{ + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, + Periods: []channelTimePricingPeriodRequest{{ + StartTime: "09:00", EndTime: "12:00", Multiplier: 2, + }}, + }, + } + + got := pricingRequestToService([]channelModelPricingRequest{req}, true) + require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone) + require.True(t, got[0].TimePricing.WeekdaysOnly) + require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier) +} + +func TestPricingRequestToService_TimePricingNil(t *testing.T) { + got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}, true) + require.Nil(t, got[0].TimePricing) +} + +// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃, +// 避免渠道倍率意外污染账号成本口径。 +func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { + req := channelModelPricingRequest{ + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + Intervals: []pricingIntervalRequest{{ + MinTokens: 272000, + InputMultiplier: float64Ptr(2), + OutputMultiplier: float64Ptr(1.5), + CacheWriteMultiplier: float64Ptr(2), + CacheReadMultiplier: float64Ptr(2), + }}, + } + + allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) + require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) + require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) + require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheReadMultiplier) + + dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) + require.Nil(t, dropped[0].FastMultiplier) + require.Nil(t, dropped[0].FlexMultiplier) + require.Nil(t, dropped[0].Intervals[0].InputMultiplier) + require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) + require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) + require.Nil(t, dropped[0].Intervals[0].CacheReadMultiplier) + // 非倍率字段不受开关影响 + require.Equal(t, 272000, dropped[0].Intervals[0].MinTokens) +} + +func TestPricingToResponse_TimePricing(t *testing.T) { + got := pricingToResponse(&service.ChannelModelPricing{ + BillingMode: service.BillingModeToken, + TimePricing: &service.ChannelTimePricing{ + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, + Periods: []service.ChannelTimePricingPeriod{{ + StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25, + }}, + }, + }) + + require.NotNil(t, got.TimePricing) + require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone) + require.True(t, got.TimePricing.WeekdaysOnly) + require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier) +} + +func TestPricingToResponse_TimePricingNil(t *testing.T) { + got := pricingToResponse(&service.ChannelModelPricing{}) + require.Nil(t, got.TimePricing) +} + // --------------------------------------------------------------------------- // 3. SyncPricingModels handler // --------------------------------------------------------------------------- @@ -459,7 +542,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) { svc := service.NewPricingService(nil, nil) router := setupSyncPricingModelsRouter(svc) - for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} { + for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} { req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index ec5735f0ca..4832be2dec 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -39,10 +40,10 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch type channelMonitorCreateRequest struct { Name string `json:"name" binding:"required,max=100"` - Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"` + Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"` APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` - Endpoint string `json:"endpoint" binding:"required,max=500"` - APIKey string `json:"api_key" binding:"required,max=2000"` + Endpoint string `json:"endpoint" binding:"omitempty,max=500"` + APIKey string `json:"api_key" binding:"omitempty,max=2000"` PrimaryModel string `json:"primary_model" binding:"max=200"` ExtraModels []string `json:"extra_models"` GroupName string `json:"group_name" binding:"max=100"` @@ -53,11 +54,17 @@ type channelMonitorCreateRequest struct { ExtraHeaders map[string]string `json:"extra_headers"` BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"` BodyOverride map[string]any `json:"body_override"` + + // CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key + // 可空(条件必填校验在 service 层按模式分支)。 + CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"` + // AccountID: 配额模式关联的账号 ID。 + AccountID *int64 `json:"account_id"` } type channelMonitorUpdateRequest struct { Name *string `json:"name" binding:"omitempty,max=100"` - Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"` + Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"` APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` Endpoint *string `json:"endpoint" binding:"omitempty,max=500"` APIKey *string `json:"api_key" binding:"omitempty,max=2000"` @@ -72,6 +79,10 @@ type channelMonitorUpdateRequest struct { ExtraHeaders *map[string]string `json:"extra_headers"` BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"` BodyOverride *map[string]any `json:"body_override"` + + // CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。 + CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"` + AccountID *int64 `json:"account_id"` } type channelMonitorResponse struct { @@ -101,25 +112,33 @@ type channelMonitorResponse struct { ExtraHeaders map[string]string `json:"extra_headers"` BodyOverrideMode string `json:"body_override_mode"` BodyOverride map[string]any `json:"body_override"` + + // 配额模式:check_mode + 关联账号 + 主模型最近配额快照 + // (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。 + CheckMode string `json:"check_mode"` + AccountID *int64 `json:"account_id"` + LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"` } type channelMonitorCheckResultResponse struct { - Model string `json:"model"` - Status string `json:"status"` - LatencyMs *int `json:"latency_ms"` - PingLatencyMs *int `json:"ping_latency_ms"` - Message string `json:"message"` - CheckedAt string `json:"checked_at"` + Model string `json:"model"` + Status string `json:"status"` + LatencyMs *int `json:"latency_ms"` + PingLatencyMs *int `json:"ping_latency_ms"` + Message string `json:"message"` + CheckedAt string `json:"checked_at"` + Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"` } type channelMonitorHistoryItemResponse struct { - ID int64 `json:"id"` - Model string `json:"model"` - Status string `json:"status"` - LatencyMs *int `json:"latency_ms"` - PingLatencyMs *int `json:"ping_latency_ms"` - Message string `json:"message"` - CheckedAt string `json:"checked_at"` + ID int64 `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + LatencyMs *int `json:"latency_ms"` + PingLatencyMs *int `json:"ping_latency_ms"` + Message string `json:"message"` + CheckedAt string `json:"checked_at"` + Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"` } // maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。 @@ -163,7 +182,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse ExtraHeaders: headers, BodyOverrideMode: m.BodyOverrideMode, BodyOverride: m.BodyOverride, - // PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。 + CheckMode: m.CheckMode, + AccountID: m.AccountID, + // PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota + // 由 List handler 在批量聚合后填充。 } if m.LastCheckedAt != nil { s := m.LastCheckedAt.UTC().Format(time.RFC3339) @@ -180,6 +202,7 @@ func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResp PingLatencyMs: r.PingLatencyMs, Message: r.Message, CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339), + Quota: r.Quota, } } @@ -192,6 +215,7 @@ func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonito PingLatencyMs: e.PingLatencyMs, Message: e.Message, CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339), + Quota: e.Quota, } } @@ -270,6 +294,7 @@ func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorSta resp.PrimaryStatus = summary.PrimaryStatus resp.PrimaryLatencyMs = summary.PrimaryLatencyMs resp.Availability7d = summary.Availability7d + resp.LatestQuota = summary.LatestQuota resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels)) for _, e := range summary.ExtraModels { resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{ @@ -327,6 +352,8 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) { ExtraHeaders: req.ExtraHeaders, BodyOverrideMode: req.BodyOverrideMode, BodyOverride: req.BodyOverride, + CheckMode: req.CheckMode, + AccountID: req.AccountID, }) if err != nil { response.ErrorFrom(c, err) @@ -421,6 +448,8 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) { ExtraHeaders: req.ExtraHeaders, BodyOverrideMode: req.BodyOverrideMode, BodyOverride: req.BodyOverride, + CheckMode: req.CheckMode, + AccountID: req.AccountID, }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/cn_provider_handler.go b/backend/internal/handler/admin/cn_provider_handler.go new file mode 100644 index 0000000000..7c8d67848a --- /dev/null +++ b/backend/internal/handler/admin/cn_provider_handler.go @@ -0,0 +1,69 @@ +package admin + +import ( + "strconv" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// CNProviderHandler 暴露国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。 +// +// - GET /admin/cn-providers/accounts/:id/quota Coding Plan 滚动窗口用量(kimi/zhipu) +// - GET /admin/cn-providers/accounts/:id/balance payg 账号余额(kimi/deepseek) +// +// 智谱(zhipu)无余额端点,故同一账号仅 quota 或 balance 其一可用:服务端按账号 +// platform + account_mode 校验并返回明确错误(见 CNProvider*Service 的 load*Account)。 +type CNProviderHandler struct { + quotaService *service.CNProviderQuotaService + balanceService *service.CNProviderBalanceService +} + +func NewCNProviderHandler( + quotaService *service.CNProviderQuotaService, + balanceService *service.CNProviderBalanceService, +) *CNProviderHandler { + return &CNProviderHandler{ + quotaService: quotaService, + balanceService: balanceService, + } +} + +// QueryQuota 查询 Coding Plan 滚动窗口用量(5h + weekly)。 +func (h *CNProviderHandler) QueryQuota(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h == nil || h.quotaService == nil { + response.BadRequest(c, "cn provider quota service is not enabled") + return + } + result, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +// QueryBalance 查询 payg 账号余额。 +func (h *CNProviderHandler) QueryBalance(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h == nil || h.balanceService == nil { + response.BadRequest(c, "cn provider balance service is not enabled") + return + } + result, err := h.balanceService.QueryBalance(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 696a24b419..fdab4473de 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -615,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) { response.BadRequest(c, "grok quota service is not enabled") return } + // ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。 + //nolint:staticcheck // SA4023 result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID) - if err != nil { + if err != nil { //nolint:staticcheck // SA4023 response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index b8b9587f72..99833e912a 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -8,11 +8,11 @@ import ( "log/slog" "strconv" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation" "github.com/Wei-Shaw/sub2api/internal/service" @@ -98,7 +98,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` @@ -166,7 +166,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` @@ -234,7 +234,7 @@ type UpdateGroupRequest struct { type CompositeRouteRequest struct { PublicModel string `json:"public_model" binding:"required"` MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"` - TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"` + TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"` UpstreamModel string `json:"upstream_model"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` Priority int `json:"priority"` @@ -734,12 +734,10 @@ func (h *GroupHandler) GetStats(c *gin.Context) { _ = groupID // TODO: implement actual stats } -// GetUsageSummary returns today's and cumulative cost for all groups. -// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai +// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups. +// GET /api/v1/admin/groups/usage-summary func (h *GroupHandler) GetUsageSummary(c *gin.Context) { - userTZ := c.Query("timezone") - now := timezone.NowInUserLocation(userTZ) - todayStart := timezone.StartOfDayInUserLocation(now, userTZ) + todayStart := service.GroupUsageTodayStart(time.Now()) results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) if err != nil { diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go new file mode 100644 index 0000000000..aeba6d1c5a --- /dev/null +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -0,0 +1,81 @@ +//go:build unit + +package admin + +import ( + "bytes" + "fmt" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验 +// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致 +// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。 +func bindGroupPlatformJSON(t *testing.T, target any, body string) error { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c.ShouldBindJSON(target) +} + +func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) { + allowed := []string{ + "anthropic", "openai", "gemini", "antigravity", "grok", + "kimi", "zhipu", "deepseek", "composite", + } + for _, platform := range allowed { + t.Run("create_"+platform, func(t *testing.T) { + var req CreateGroupRequest + body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform) + require.NoError(t, bindGroupPlatformJSON(t, &req, body), + "platform %q 应通过 CreateGroupRequest 校验", platform) + require.Equal(t, platform, req.Platform) + }) + t.Run("update_"+platform, func(t *testing.T) { + var req UpdateGroupRequest + body := fmt.Sprintf(`{"platform":%q}`, platform) + require.NoError(t, bindGroupPlatformJSON(t, &req, body), + "platform %q 应通过 UpdateGroupRequest 校验", platform) + require.Equal(t, platform, req.Platform) + }) + } +} + +func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { + invalid := []string{ + "moonshot", // 厂商别名,不是平台标识 + "Kimi", // 大小写敏感 + "openai ", // 尾随空格 + "glm", + "bogus", + } + for _, platform := range invalid { + t.Run("create_"+platform, func(t *testing.T) { + var req CreateGroupRequest + body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform) + require.Error(t, bindGroupPlatformJSON(t, &req, body), + "platform %q 应被 CreateGroupRequest 拒绝", platform) + }) + t.Run("update_"+platform, func(t *testing.T) { + var req UpdateGroupRequest + body := fmt.Sprintf(`{"platform":%q}`, platform) + require.Error(t, bindGroupPlatformJSON(t, &req, body), + "platform %q 应被 UpdateGroupRequest 拒绝", platform) + }) + } +} + +func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) { + for _, platform := range []string{"kimi", "zhipu", "deepseek"} { + var req CompositeRouteRequest + body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform) + require.NoError(t, bindGroupPlatformJSON(t, &req, body)) + require.Equal(t, platform, req.TargetPlatform) + } +} diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index d7cfc8d2a6..779e4455cd 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -34,12 +34,6 @@ type openAIAccountStateRecoverer interface { RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) } -const ( - openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" - openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" - openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" -) - // openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the // (non-refundable) reset credit has already been consumed upstream. The whole // request must stay comfortably inside the panel HTTP client timeout, otherwise @@ -493,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.ErrorFrom(c, err) return } + service.NotifyOpenAIAutoResetCredit(accountID) response.Success(c, usage) } @@ -523,6 +518,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result") return } + service.NotifyOpenAIAutoResetCredit(accountID) refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} // A failed snapshot write leaves the previous cache intact — report it as a @@ -600,54 +596,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context()) defer cancelPost() - // Step 1 — unblocking the account is the whole point of consuming a credit - // (#3672 / #3740), so it runs FIRST and is never gated on the display cache. - // Recovery is DB-only and leaves the manual `schedulable` switch untouched. - if h.rateLimitService == nil { - resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed - response.Success(c, resetResponse) - return + postResult := service.RunOpenAIQuotaResetPostProcess( + postCtx, + accountID, + h.quotaService, + h.rateLimitService, + h.adminService.GetAccount, + ) + resetResponse.Quota = postResult.Quota + resetResponse.CacheRefreshed = postResult.CacheRefreshed + resetResponse.AccountStateRecovered = postResult.AccountStateRecovered + resetResponse.WarningCode = postResult.WarningCode + if postResult.Account != nil { + resetResponse.Account = dto.AccountFromService(postResult.Account) } - if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{ - InvalidateToken: true, - }); err != nil { - // Recovery failures are almost always storage-level; the remaining steps - // share that dependency, so stop here instead of compounding the failure. - slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err) - resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed - response.Success(c, resetResponse) - return - } - resetResponse.AccountStateRecovered = true - - // Step 2 — refresh the reset-credit display cache. A failure here is reported - // but must not hide the recovered account row produced by step 3. - usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID) - switch { - case usageErr != nil || usage == nil: - slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr) - resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed - default: - if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil { - slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err) - resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed - } else { - resetResponse.Quota = usage - resetResponse.CacheRefreshed = true - } - } - - // Step 3 — hand back the post-recovery account row so the list drops the - // stale rate-limit badge without waiting for the next poll. - account, err := h.adminService.GetAccount(postCtx, accountID) - if err != nil { - slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err) - if resetResponse.WarningCode == "" { - resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed - } - response.Success(c, resetResponse) - return - } - resetResponse.Account = dto.AccountFromService(account) response.Success(c, resetResponse) } diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go index c0e3ca161d..b9e8dac679 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -215,7 +215,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) require.False(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -237,7 +237,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) require.False(t, envelope.Data.AccountStateRecovered) require.Zero(t, quota.queryCalls) require.Zero(t, adminService.calls) @@ -260,7 +260,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -285,7 +285,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -307,7 +307,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.CacheRefreshed) require.True(t, envelope.Data.AccountStateRecovered) require.NotNil(t, envelope.Data.Quota) @@ -331,7 +331,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.Nil(t, envelope.Data.Account) } @@ -435,7 +435,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.NotNil(t, envelope.Data.Account) require.Zero(t, quota.cacheCalls) diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go new file mode 100644 index 0000000000..a0967c6efe --- /dev/null +++ b/backend/internal/handler/admin/plugin_handler.go @@ -0,0 +1,257 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const pluginUISessionTTL = 30 * time.Minute + +// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。 +type PluginHandler struct { + manager *service.PluginManager +} + +func NewPluginHandler(manager *service.PluginManager) *PluginHandler { + return &PluginHandler{manager: manager} +} + +func (h *PluginHandler) List(c *gin.Context) { + plugins, err := h.manager.List(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugins) +} + +func (h *PluginHandler) Get(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + plugin, err := h.manager.Get(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Upload(c *gin.Context) { + maxBytes := h.manager.MaxUploadBytes() + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20)) + file, header, err := c.Request.FormFile("plugin") + if err != nil { + response.BadRequest(c, "请选择有效的 .s2plugin 文件") + return + } + defer func() { _ = file.Close() }() + if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") { + response.BadRequest(c, "插件包扩展名必须是 .s2plugin") + return + } + var installedBy *int64 + if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 { + userID := subject.UserID + installedBy = &userID + } + plugin, err := h.manager.Install(c.Request.Context(), file, installedBy) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, plugin) +} + +type pluginEnableRequest struct { + AcceptUntested bool `json:"accept_untested"` + RolloutPercent int `json:"rollout_percent"` +} + +func (h *PluginHandler) Enable(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + request := pluginEnableRequest{RolloutPercent: 100} + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "启用参数无效") + return + } + plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Disable(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + plugin, err := h.manager.Disable(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Delete(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + if err := h.manager.Delete(c.Request.Context(), id); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"message": "插件已卸载"}) +} + +func (h *PluginHandler) GetConfig(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + configJSON, err := h.manager.GetConfig(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON) +} + +func (h *PluginHandler) SaveConfig(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + response.BadRequest(c, "插件配置必须是有效 JSON") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + response.BadRequest(c, "插件配置只能包含一个 JSON 值") + return + } + raw, err := json.Marshal(value) + if err != nil { + response.BadRequest(c, "插件配置无法序列化") + return + } + saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", saved) +} + +func (h *PluginHandler) Test(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + result, err := h.manager.Test(c.Request.Context(), id) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, result) +} + +func (h *PluginHandler) CreateUISession(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL) + if err != nil { + response.ErrorFrom(c, err) + return + } + bridgeToken, err := randomPluginToken() + if err != nil { + response.InternalError(c, "创建插件 UI Bridge 失败") + return + } + response.Success(c, gin.H{ + "url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken), + "bridge_token": bridgeToken, + "ui_bridge_version": 1, + "expires_at": expires, + }) +} + +// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。 +func (h *PluginHandler) ServeUIAsset(c *gin.Context) { + token := strings.TrimSpace(c.Param("token")) + pluginID, err := h.manager.ResolveUIAssetToken(token) + if err != nil { + c.Status(http.StatusGone) + return + } + relative := strings.TrimPrefix(c.Param("path"), "/") + data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.Status(http.StatusNotFound) + return + } + c.Status(http.StatusNotFound) + return + } + contentType := mime.TypeByExtension(filepath.Ext(logicalPath)) + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Cache-Control", "private, no-store") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "SAMEORIGIN") + // sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。 + // 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。 + c.Header("Cross-Origin-Resource-Policy", "cross-origin") + c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'") + c.Data(http.StatusOK, contentType, data) +} + +func pluginIDParam(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "插件 ID 无效") + return 0, false + } + return id, true +} + +func randomPluginToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index a5f3d41d90..4c8cc145dc 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -374,6 +374,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, GrokDefaultTextModel: settings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled, @@ -381,9 +382,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - ModelPlazaEnabled: settings.ModelPlazaEnabled, - ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, - ModelPlazaDescription: settings.ModelPlazaDescription, + ModelPlazaEnabled: settings.ModelPlazaEnabled, + ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + PluginManagementEnabled: settings.PluginManagementEnabled, + ModelPlazaDescription: settings.ModelPlazaDescription, AffiliateEnabled: settings.AffiliateEnabled, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 5884dc392e..b2c001fe73 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -332,6 +332,7 @@ type UpdateSettingsRequest struct { ChannelMonitorMode *string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"` // Grok model mapping policy GrokDefaultTextModel *string `json:"grok_default_text_model"` @@ -346,6 +347,9 @@ type UpdateSettingsRequest struct { ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"` ModelPlazaDescription *string `json:"model_plaza_description"` + // Plugin management menu visibility switch; plugin runtime is unaffected. + PluginManagementEnabled *bool `json:"plugin_management_enabled"` + // Affiliate (邀请返利) feature switch AffiliateEnabled *bool `json:"affiliate_enabled"` @@ -437,7 +441,7 @@ func buildSettingKeyByJSONName() map[string]string { out := make(map[string]string, t.NumField()) for i := 0; i < t.NumField(); i++ { field := t.Field(i) - if field.Type.Kind() == reflect.Ptr { + if field.Type.Kind() == reflect.Pointer { continue } name, _, _ := strings.Cut(field.Tag.Get("json"), ",") @@ -1889,6 +1893,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ChannelMonitorHideThroughput }(), + ChannelMonitorShowQuota: func() bool { + if req.ChannelMonitorShowQuota != nil { + return *req.ChannelMonitorShowQuota + } + return previousSettings.ChannelMonitorShowQuota + }(), GrokDefaultTextModel: func() string { if req.GrokDefaultTextModel != nil { return *req.GrokDefaultTextModel @@ -1931,6 +1941,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ModelPlazaDescription }(), + PluginManagementEnabled: func() bool { + if req.PluginManagementEnabled != nil { + return *req.PluginManagementEnabled + } + return previousSettings.PluginManagementEnabled + }(), AffiliateEnabled: func() bool { if req.AffiliateEnabled != nil { return *req.AffiliateEnabled @@ -2342,6 +2358,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ChannelMonitorMode: updatedSettings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota, GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled, @@ -2349,9 +2366,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, - ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled, - ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth, - ModelPlazaDescription: updatedSettings.ModelPlazaDescription, + ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled, + ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth, + ModelPlazaDescription: updatedSettings.ModelPlazaDescription, + PluginManagementEnabled: updatedSettings.PluginManagementEnabled, AffiliateEnabled: updatedSettings.AffiliateEnabled, diff --git a/backend/internal/handler/admin/user_platform_quota_admin_test.go b/backend/internal/handler/admin/user_platform_quota_admin_test.go index 0211480b82..f1d1b5b605 100644 --- a/backend/internal/handler/admin/user_platform_quota_admin_test.go +++ b/backend/internal/handler/admin/user_platform_quota_admin_test.go @@ -112,12 +112,13 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) { if len(repo.upsertCalls) != 1 { t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls)) } - if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) { + // upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。 + if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 { t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0]) } - // 缓存失效:按全部允许平台统一失效。 - if len(cache.deleteCalls) != 5 { - t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls) + // 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。 + if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) { + t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls) } } diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 0367e4cff0..13c26cf18c 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -1144,10 +1144,10 @@ func (k oidcJWK) publicKey() (any, error) { if err != nil { return nil, fmt.Errorf("decode ec y: %w", err) } - if !curve.IsOnCurve(x, y) { + if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移 return nil, errors.New("ec point is not on curve") } - return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上 default: return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty) } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 300eb1b1a7..a6b7a36a3c 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -284,13 +284,13 @@ func toUserSupportedModels( return out } -// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。 -func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { - if p == nil { +// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。 +func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO { + if src == nil { return nil } - intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals)) - for _, iv := range p.Intervals { + intervals := make([]userPricingIntervalDTO, 0, len(src)) + for _, iv := range src { intervals = append(intervals, userPricingIntervalDTO{ MinTokens: iv.MinTokens, MaxTokens: iv.MaxTokens, @@ -302,6 +302,19 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { PerRequestPrice: iv.PerRequestPrice, }) } + return intervals +} + +// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。 +func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { + if p == nil { + return nil + } + intervals := toUserPricingIntervals(p.Intervals) + if intervals == nil { + // 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。 + intervals = []userPricingIntervalDTO{} + } billingMode := string(p.BillingMode) if billingMode == "" { billingMode = string(service.BillingModeToken) diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index 7e42c5a74d..173ca56dfe 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -3,6 +3,7 @@ package handler import ( "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/admin" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -39,6 +40,15 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool { return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1 } +// quotaVisible 返回用户端是否展示配额/余额快照(channel_monitor_show_quota, +// fail-closed:未配置/非 "true" 一律视为关闭)。settingService 为 nil 时 fail-closed。 +func (h *ChannelMonitorUserHandler) quotaVisible(c *gin.Context) bool { + if h.settingService == nil { + return false + } + return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).ShowQuota +} + // --- Response --- type channelMonitorUserListItem struct { @@ -53,6 +63,9 @@ type channelMonitorUserListItem struct { Availability7d float64 `json:"availability_7d"` ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"` Timeline []channelMonitorUserTimelinePoint `json:"timeline"` + // LatestQuota 主模型最近配额快照;channel_monitor_show_quota=false 时 + // 由 userMonitorViewToItem 的调用方传入 false 剥离(服务端脱敏,非仅前端隐藏)。 + LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"` } // channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。 @@ -82,7 +95,7 @@ type channelMonitorUserModelStat struct { AvgLatency7dMs *int `json:"avg_latency_7d_ms"` } -func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListItem { +func userMonitorViewToItem(v *service.UserMonitorView, includeQuota bool) channelMonitorUserListItem { extras := make([]dto.ChannelMonitorExtraModelStatus, 0, len(v.ExtraModels)) for _, e := range v.ExtraModels { extras = append(extras, dto.ChannelMonitorExtraModelStatus{ @@ -100,7 +113,7 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339), }) } - return channelMonitorUserListItem{ + item := channelMonitorUserListItem{ ID: v.ID, Name: v.Name, Provider: v.Provider, @@ -113,6 +126,10 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte ExtraModels: extras, Timeline: timeline, } + if includeQuota { + item.LatestQuota = v.LatestQuota + } + return item } func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUserDetailResponse { @@ -150,9 +167,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) { response.ErrorFrom(c, err) return } + includeQuota := h.quotaVisible(c) items := make([]channelMonitorUserListItem, 0, len(views)) for _, v := range views { - items = append(items, userMonitorViewToItem(v)) + items = append(items, userMonitorViewToItem(v, includeQuota)) } response.Success(c, gin.H{"items": items}) } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 2fa79c538b..6e068a4614 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -22,18 +22,43 @@ func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) { require.Equal(t, service.PlatformOpenAI, platform) } -func TestOpenAICompatibleTextTargetAllowsCompositeGrokModel(t *testing.T) { +func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { gin.SetMode(gin.TestMode) - for _, path := range []string{"/v1/messages", "/v1/chat/completions"} { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest("POST", path, nil) - apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} + providers := []struct { + model string + platform string + }{ + {model: "grok-4.3", platform: service.PlatformGrok}, + {model: "kimi-k2-thinking", platform: service.PlatformKimi}, + {model: "k3", platform: service.PlatformKimi}, + {model: "glm-5.2", platform: service.PlatformZhipu}, + {model: "deepseek-v3.2", platform: service.PlatformDeepseek}, + } + for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} { + for _, provider := range providers { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", path, nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} - require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, "grok-4.3"), "path=%s", path) - platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()) - require.True(t, ok, "path=%s", path) - require.Equal(t, service.PlatformGrok, platform, "path=%s", path) + require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model) + platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()) + require.True(t, ok, "path=%s model=%s", path, provider.model) + require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model) + } + } +} + +// WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换, +// 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。 +func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) { + require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI)) + require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok)) + for _, platform := range []string{ + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformAnthropic, service.PlatformGemini, + } { + require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform) } } diff --git a/backend/internal/handler/dto/public_settings_injection_schema_test.go b/backend/internal/handler/dto/public_settings_injection_schema_test.go index 428fed3d8a..6a35c8867e 100644 --- a/backend/internal/handler/dto/public_settings_injection_schema_test.go +++ b/backend/internal/handler/dto/public_settings_injection_schema_test.go @@ -29,8 +29,6 @@ func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) { // Fields that legitimately live only on the DTO. Keep tiny; document each. dtoOnlyFields := map[string]string{ - // sora_client_enabled is an upstream-only field the fork does not surface. - "sora_client_enabled": "upstream-only field, not used on this fork", // force_email_on_third_party_signup lives on the DTO but is not injected via SSR. "force_email_on_third_party_signup": "auth-source default, not a feature flag", } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 5ab5eaa68b..0d0c1ed40f 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -305,6 +305,7 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -315,9 +316,10 @@ type SystemSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` // Model Plaza feature (public group/model pricing showcase) - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - ModelPlazaDescription string `json:"model_plaza_description"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaDescription string `json:"model_plaza_description"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_enabled"` @@ -397,7 +399,6 @@ type PublicSettings struct { OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` GitHubOAuthEnabled bool `json:"github_oauth_enabled"` GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - SoraClientEnabled bool `json:"sora_client_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` @@ -414,11 +415,13 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` AffiliateEnabled bool `json:"affiliate_enabled"` diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index df8776981c..d13bbee47a 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -15,20 +15,21 @@ import ( // ────────────────────────────────────────────────────────── const ( - EndpointMessages = "/v1/messages" - EndpointChatCompletions = "/v1/chat/completions" - EndpointEmbeddings = "/v1/embeddings" - EndpointAlphaSearch = "/v1/alpha/search" - EndpointResponses = "/v1/responses" - EndpointResponsesCompact = "/v1/responses/compact" - EndpointImagesGenerations = "/v1/images/generations" - EndpointImagesEdits = "/v1/images/edits" - EndpointImageTasks = "/v1/images/tasks" - EndpointVideosGenerations = "/v1/videos/generations" - EndpointVideosEdits = "/v1/videos/edits" - EndpointVideosExtensions = "/v1/videos/extensions" - EndpointVideos = "/v1/videos" - EndpointGeminiModels = "/v1beta/models" + EndpointMessages = "/v1/messages" + EndpointChatCompletions = "/v1/chat/completions" + EndpointEmbeddings = "/v1/embeddings" + EndpointAlphaSearch = "/v1/alpha/search" + EndpointResponses = "/v1/responses" + EndpointResponsesCompact = "/v1/responses/compact" + EndpointResponsesInputTokens = "/v1/responses/input_tokens" + EndpointImagesGenerations = "/v1/images/generations" + EndpointImagesEdits = "/v1/images/edits" + EndpointImageTasks = "/v1/images/tasks" + EndpointVideosGenerations = "/v1/videos/generations" + EndpointVideosEdits = "/v1/videos/edits" + EndpointVideosExtensions = "/v1/videos/extensions" + EndpointVideos = "/v1/videos" + EndpointGeminiModels = "/v1beta/models" ) const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent" @@ -80,6 +81,8 @@ const ( func NormalizeInboundEndpoint(path string) string { path = strings.TrimSpace(path) switch { + case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path): + return EndpointResponsesInputTokens case strings.Contains(path, EndpointEmbeddings): return EndpointEmbeddings case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"): @@ -113,6 +116,15 @@ func NormalizeInboundEndpoint(path string) string { } } +func isResponsesInputTokensAliasPath(path string) bool { + trimmed := strings.TrimRight(strings.TrimSpace(path), "/") + if trimmed == "" { + return false + } + return isBareOrSubpathOf(trimmed, "/responses/input_tokens") || + isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens") +} + // isResponsesCompactAliasPath reports whether path is the bare/alias // "compact" client endpoint — i.e. it is rooted at "/responses/compact" // or "/backend-api/codex/responses/compact" (bare routes that serve @@ -185,7 +197,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { switch platform { case service.PlatformOpenAI, service.PlatformGrok: - if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos { + if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos { return inbound } // OpenAI forwards everything to the Responses API. @@ -300,6 +312,14 @@ func GetInboundEndpoint(c *gin.Context) string { // and the account platform. Handlers call this after scheduling an // account, passing account.Platform. func GetUpstreamEndpoint(c *gin.Context, platform string) string { + // OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。 + // 这对 force_chat_completions 的错误路径尤为重要:此时可能没有 + // ForwardResult,不能把入站 /v1/responses 误报成上游端点。 + if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) { + if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" { + return endpoint + } + } if c != nil { if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok { if endpoint, ok := value.(string); ok && endpoint != "" { diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index 3f7b994702..f87b27fcd1 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -27,6 +27,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/v1/embeddings", EndpointEmbeddings}, {"/v1/alpha/search", EndpointAlphaSearch}, {"/v1/responses", EndpointResponses}, + {"/v1/responses/input_tokens", EndpointResponsesInputTokens}, {"/v1/responses/compact", EndpointResponsesCompact}, {"/v1/responses/compact/detail", EndpointResponsesCompact}, {"/v1/images/generations", EndpointImagesGenerations}, @@ -50,6 +51,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { // Bare top-level alias route "/responses" — root vs. compact. {"/responses", EndpointResponses}, + {"/responses/input_tokens", EndpointResponsesInputTokens}, {"/responses/compact", EndpointResponsesCompact}, {"/responses/compact/detail", EndpointResponsesCompact}, {"/alpha/search", EndpointAlphaSearch}, @@ -57,6 +59,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { // Bare Codex direct alias route — root vs. compact. {"/backend-api/codex/responses", EndpointResponses}, + {"/backend-api/codex/responses/input_tokens", EndpointResponsesInputTokens}, {"/backend-api/codex/responses/compact", EndpointResponsesCompact}, {"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact}, {"/backend-api/codex/alpha/search", EndpointAlphaSearch}, @@ -100,6 +103,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) { // OpenAI — root Responses. {"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses}, + {"openai responses input tokens", EndpointResponsesInputTokens, "/v1/responses/input_tokens", service.PlatformOpenAI, EndpointResponsesInputTokens}, // OpenAI — compact, raw path carries the derivable "/compact" // (or nested) suffix, which must be preserved on the upstream @@ -180,6 +184,16 @@ func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) { require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity)) } +func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil) + c.Set(ctxKeyInboundEndpoint, EndpointResponses) + + service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions) + require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI)) +} + func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index edbe4e232a..56c39af103 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -56,7 +56,13 @@ const ( const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control" func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration { - if failoverErr == nil || !failoverErr.RequestScopedTransient || retryCount <= 1 { + if failoverErr == nil { + return sameAccountRetryDelay + } + if failoverErr.SameAccountRetryDelay > 0 { + return failoverErr.SameAccountRetryDelay + } + if !failoverErr.RequestScopedTransient || retryCount <= 1 { return sameAccountRetryDelay } @@ -70,6 +76,51 @@ func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryC return delay } +func sameAccountRetryAllowed(failoverErr *service.UpstreamFailoverError, retryCount, retryLimit int) bool { + if failoverErr == nil || !failoverErr.RetryableOnSameAccount { + return false + } + if !sameAccountRetryDeadlineAllows(failoverErr) { + return false + } + // Error-specific caps (Grok capacity/stream-idle) remain hard limits even + // when the error also carries a freshly reconstructed deadline. + if failoverErr.SameAccountRetryMax > 0 { + if retryLimit <= 0 { + return false + } + if failoverErr.SameAccountRetryMax < retryLimit { + retryLimit = failoverErr.SameAccountRetryMax + } + return retryCount < retryLimit + } + // OAuth 429 explicitly opts into a deadline window. It is intentionally not + // bounded by the ordinary/default pool retry count. + if !failoverErr.SameAccountRetryDeadline.IsZero() { + return true + } + return retryLimit > 0 && retryCount < retryLimit +} + +// sameAccountRetryDeadlineAllows prevents a retry from starting after the +// service-provided same-account retry window has elapsed. +func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool { + return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline) +} + +// effectiveSameAccountRetryLimit applies an error-specific cap without +// overriding an explicit account setting of zero (which disables retries). +func effectiveSameAccountRetryLimit(failoverErr *service.UpstreamFailoverError, account *service.Account) int { + if account == nil { + return 0 + } + limit := account.GetPoolModeRetryCount() + if limit > 0 && failoverErr != nil && failoverErr.SameAccountRetryMax > 0 && failoverErr.SameAccountRetryMax < limit { + return failoverErr.SameAccountRetryMax + } + return limit +} + // FailoverState 跨循环迭代共享的 failover 状态 type FailoverState struct { SwitchCount int @@ -158,14 +209,15 @@ func (s *FailoverState) HandleFailoverError( } // 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。 - sameAccountRetry := failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit + retryCount := s.SameAccountRetryCount[accountID] + sameAccountRetry := sameAccountRetryAllowed(failoverErr, retryCount, retryLimit) if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) { s.ForceCacheBilling = true } // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。 // 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。 - if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit { + if sameAccountRetry { s.SameAccountRetryCount[accountID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID]) logger.FromContext(ctx).Warn("gateway.failover_same_account_retry", diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index e65b43c172..d362682cd7 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -58,6 +58,60 @@ func TestSameAccountRetryDelayFor(t *testing.T) { t.Run("nil error keeps fixed delay", func(t *testing.T) { require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(nil, 10)) }) + + t.Run("explicit oauth delay wins", func(t *testing.T) { + err := &service.UpstreamFailoverError{SameAccountRetryDelay: 3 * time.Second} + require.Equal(t, 3*time.Second, sameAccountRetryDelayFor(err, 1)) + }) +} + +func TestSameAccountRetryAllowedUsesDeadlineInsteadOfPoolCount(t *testing.T) { + err := &service.UpstreamFailoverError{ + RetryableOnSameAccount: true, + SameAccountRetryDeadline: time.Now().Add(time.Minute), + } + require.True(t, sameAccountRetryAllowed(err, 100, 0)) + require.True(t, sameAccountRetryAllowed(err, 100, maxSameAccountRetries)) + err.SameAccountRetryDeadline = time.Now().Add(-time.Second) + require.False(t, sameAccountRetryAllowed(err, 0, 100)) +} + +func TestSameAccountRetryAllowedRequiresOptInAndDefaultsToCountLimit(t *testing.T) { + err := &service.UpstreamFailoverError{SameAccountRetryDeadline: time.Now().Add(time.Minute)} + require.False(t, sameAccountRetryAllowed(err, 0, maxSameAccountRetries)) + + err.RetryableOnSameAccount = true + err.SameAccountRetryDeadline = time.Time{} + require.True(t, sameAccountRetryAllowed(err, maxSameAccountRetries-1, maxSameAccountRetries)) + require.False(t, sameAccountRetryAllowed(err, maxSameAccountRetries, maxSameAccountRetries)) +} + +func TestSameAccountRetryAllowedHonorsErrorMaxBeforeDeadline(t *testing.T) { + err := &service.UpstreamFailoverError{ + RetryableOnSameAccount: true, + SameAccountRetryDeadline: time.Now().Add(time.Minute), + SameAccountRetryMax: 1, + } + require.True(t, sameAccountRetryAllowed(err, 0, maxSameAccountRetries)) + require.False(t, sameAccountRetryAllowed(err, 1, maxSameAccountRetries)) + require.False(t, sameAccountRetryAllowed(err, 0, 0), "an explicit zero retry budget remains disabled") +} + +func TestSameAccountRetryDeadlineAllows(t *testing.T) { + require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{})) + require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{ + SameAccountRetryDeadline: time.Now().Add(time.Second), + })) + require.False(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{ + SameAccountRetryDeadline: time.Now().Add(-time.Second), + })) +} + +func TestEffectiveSameAccountRetryLimitHonorsErrorCapAndDisabledAccount(t *testing.T) { + account := &service.Account{Type: service.AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true, "pool_mode_retry_count": float64(3)}} + require.Equal(t, 1, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account)) + account.Credentials["pool_mode_retry_count"] = float64(0) + require.Equal(t, 0, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account)) } // --------------------------------------------------------------------------- @@ -320,6 +374,22 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) { require.Zero(t, fs.SwitchCount) }) + t.Run("OAuth deadline存在时不按普通计数切换", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, true) + fs.SameAccountRetryCount[100] = maxSameAccountRetries + err := newTestFailoverErr(http.StatusTooManyRequests, true, false) + err.SameAccountRetryDeadline = time.Now().Add(time.Minute) + err.SameAccountRetryDelay = time.Nanosecond + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err) + + require.False(t, fs.ForceCacheBilling) + require.Zero(t, fs.SwitchCount) + require.Equal(t, maxSameAccountRetries+1, fs.SameAccountRetryCount[100]) + require.Empty(t, mock.calls) + }) + t.Run("同账号重试耗尽并实际切换时设置ForceCacheBilling", func(t *testing.T) { mock := &mockTempUnscheduler{} fs := NewFailoverState(3, true) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 07315d5efc..d07cd57e13 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1140,6 +1140,79 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) } +// CodexModels returns the effective group model list using the manifest shape +// expected by Codex custom providers. Official OpenAI groups continue to use +// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved. +func (h *GatewayHandler) CodexModels(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") + return + } + + forcedPlatform := "" + if value, exists := middleware2.GetForcePlatformFromContext(c); exists { + forcedPlatform = strings.TrimSpace(value) + } + modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform) + modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group) + body, err := h.gatewayService.BuildCodexModelsManifestForGroup( + c.Request.Context(), + apiKey.Group, + forcedPlatform, + modelIDs, + ) + if err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + etag := service.CodexModelsManifestETag(body) + c.Header("ETag", etag) + if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + c.Writer.WriteHeaderNow() + return + } + c.Data(http.StatusOK, "application/json", body) +} + +func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string { + if h == nil || h.gatewayService == nil || group == nil { + return nil + } + + groupID := &group.ID + platform := strings.TrimSpace(platformOverride) + if platform == "" { + platform = group.Platform + } + if platform == service.PlatformComposite { + availableModels := h.compositeAvailableModels(ctx, groupID) + fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels + } + + availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) + fallbackModels := defaultCodexModelIDsForPlatform(platform) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList( + customModelsListSource(platform, availableModels, fallbackModels), + fallbackModels, + group.ModelsListConfig.Models, + ) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels +} + func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { if h == nil || h.gatewayService == nil { return nil @@ -1147,10 +1220,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * seen := make(map[string]struct{}) models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) - for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} { + for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { - if _, ok := schedulablePlatforms[platform]; ok { + // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 + // default 分支是 Claude 列表),composite 下只暴露账号映射键。 + if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) { platformModels = defaultModelIDsForPlatform(platform) } } @@ -1338,9 +1413,26 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool return true } } + normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking")) + if normalizedClaudeModel != model { + for _, pattern := range availablePatterns { + if pattern == normalizedClaudeModel { + return true + } + } + } return false } +func defaultCodexModelIDsForPlatform(platform string) []string { + switch platform { + case service.PlatformDeepseek: + return []string{"deepseek-v4-pro", "deepseek-v4-flash"} + default: + return defaultModelIDsForPlatform(platform) + } +} + func defaultModelIDsForPlatform(platform string) []string { switch platform { case service.PlatformOpenAI: @@ -1359,20 +1451,13 @@ func defaultModelIDsForPlatform(platform string) []string { } return ids case service.PlatformAnthropic: - ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels())) - for _, model := range claude.DefaultModels { - ids = append(ids, model.ID) - } - for _, model := range antigravity.DefaultModels() { - ids = append(ids, model.ID) - } - return mergeModelIDs(ids, nil) + return claude.DefaultModelIDs() case service.PlatformGrok: return xai.DefaultModelIDs() case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} { + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index ea4e839636..951a93a6b3 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" @@ -173,6 +174,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { if err != nil { if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform) + cls = classifySelectionFailureError(err, cls) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -384,6 +386,14 @@ func (h *GatewayHandler) handleCCFailoverExhausted(c *gin.Context, lastErr *serv h.chatCompletionsErrorResponse(c, status, "server_error", message) return } + if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" { + status := lastErr.ClientStatusCode + if status <= 0 { + status = http.StatusServiceUnavailable + } + h.chatCompletionsErrorResponse(c, status, "server_error", lastErr.ClientMessage) + return + } statusCode := http.StatusBadGateway if lastErr != nil && lastErr.StatusCode > 0 { statusCode = lastErr.StatusCode diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 7f49f39a3e..ac5a3d23fc 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" @@ -175,6 +176,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { if err != nil { if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey)) + cls = classifySelectionFailureError(err, cls) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -361,25 +363,38 @@ func (h *GatewayHandler) responsesErrorResponse(c *gin.Context, status int, code // handleResponsesFailoverExhausted writes a failover-exhausted error in Responses format. func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastErr *service.UpstreamFailoverError, streamStarted bool) { - if streamStarted { - return // Can't write error after stream started - } if lastErr != nil { copyFailoverRetryAfter(c, lastErr.ResponseHeaders) } - if lastErr != nil && lastErr.IsCredentialFailure() { - status, message := credentialFailoverClientResponse(lastErr) - h.responsesErrorResponse(c, status, "server_error", message) - return - } statusCode := http.StatusBadGateway if lastErr != nil && lastErr.StatusCode > 0 { statusCode = lastErr.StatusCode } - if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) { + status, code, message := statusCode, "server_error", "All available accounts exhausted" + if lastErr != nil && lastErr.IsCredentialFailure() { + status, message = credentialFailoverClientResponse(lastErr) + } else if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" { + status = lastErr.ClientStatusCode + if status <= 0 { + status = http.StatusServiceUnavailable + } + message = lastErr.ClientMessage + } else if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) { service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "") - h.responsesErrorResponse(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage()) + status, code, message = http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage() + } else if lastErr != nil && statusCode == http.StatusTooManyRequests { + status, code, message = http.StatusTooManyRequests, "rate_limit_error", "All available accounts are currently rate-limited. Please retry later." + } + if streamStarted { + // A slot-wait heartbeat commits HTTP 200 before any upstream response. + // In that case a terminal frame is still required; once any semantic or + // official terminal bytes exist, preserve them without appending a second + // generic response.failed. + service.MarkOpsStreamError(c, code, message, status) + if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) { + writeResponsesFailedSSE(c, code, message) + } return } - h.responsesErrorResponse(c, statusCode, "server_error", "All available accounts exhausted") + h.responsesErrorResponse(c, status, code, message) } diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go index 1f6daf5291..abc11904d4 100644 --- a/backend/internal/handler/gateway_helper.go +++ b/backend/internal/handler/gateway_helper.go @@ -16,6 +16,29 @@ import ( "github.com/gin-gonic/gin" ) +const gatewayStreamHeartbeatBytesKey = "gateway_stream_heartbeat_bytes" + +func recordGatewayStreamHeartbeat(c *gin.Context, written int) { + if c == nil || written <= 0 { + return + } + total, _ := c.Get(gatewayStreamHeartbeatBytesKey) + bytes, _ := total.(int) + c.Set(gatewayStreamHeartbeatBytesKey, bytes+written) +} + +func gatewayStreamHasOnlyHeartbeats(c *gin.Context) bool { + if c == nil || c.Writer == nil { + return false + } + value, ok := c.Get(gatewayStreamHeartbeatBytesKey) + if !ok { + return false + } + heartbeatBytes, _ := value.(int) + return heartbeatBytes > 0 && c.Writer.Size() == heartbeatBytes +} + // claudeCodeValidator is a singleton validator for Claude Code client detection var claudeCodeValidator = service.NewClaudeCodeValidator() @@ -396,9 +419,11 @@ func (h *ConcurrencyHelper) waitForSlotWithPingTimeout(c *gin.Context, slotType c.Header("X-Accel-Buffering", "no") *streamStarted = true } - if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil { + written, err := fmt.Fprint(c.Writer, string(h.pingFormat)) + if err != nil { return nil, err } + recordGatewayStreamHeartbeat(c, written) flusher.Flush() case <-timer.C: diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 58f0190297..05069bf25a 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -24,6 +25,22 @@ type gatewayModelsResponseForTest struct { Data []gatewayModelItemForTest `json:"data"` } +type codexModelsResponseForTest struct { + Models []struct { + Slug string `json:"slug"` + SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"` + InputModalities []string `json:"input_modalities"` + ModelMessages map[string]json.RawMessage `json:"model_messages"` + TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"` + AvailabilityNUX json.RawMessage `json:"availability_nux"` + Upgrade json.RawMessage `json:"upgrade"` + } `json:"models"` +} + +type codexReasoningLevelForTest struct { + Effort string `json:"effort"` +} + type gatewayModelItemForTest struct { ID string `json:"id"` Object string `json:"object"` @@ -51,6 +68,10 @@ func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Cont return out, nil } +func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) { + return s.ListSchedulableByGroupID(ctx, groupID) +} + func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler { return &GatewayHandler{ gatewayService: service.NewGatewayService( @@ -69,6 +90,247 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { require.Contains(t, compositeIDs, antigravityIDs[0]) } +// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models. +func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) { + anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic) + require.Contains(t, anthropicIDs, "claude-opus-4-6") + require.NotContains(t, anthropicIDs, "gemini-2.5-flash") + + antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity) + require.Contains(t, antigravityIDs, "gemini-2.5-flash") +} + +// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list. +func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { + tests := []struct { + name string + platform string + model string + efforts []string + modalities []string + }{ + { + name: "Grok", + platform: service.PlatformGrok, + model: "grok-4.6", + efforts: []string{"low", "medium", "high", "xhigh"}, + modalities: []string{"text", "image"}, + }, + { + name: "DeepSeek", + platform: service.PlatformDeepseek, + model: "deepseek-v4-pro", + efforts: []string{"low", "high", "max"}, + modalities: []string{"text"}, + }, + { + name: "provider-qualified Claude", + platform: service.PlatformAnthropic, + model: "anthropic/claude-sonnet-4-6", + efforts: []string{"low", "medium", "high", "max"}, + modalities: []string{"text"}, + }, + } + + for index, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(100 + index) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: tt.platform, + Credentials: map[string]any{ + "model_mapping": map[string]any{tt.model: tt.model}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: tt.platform}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Len(t, got.Models, 1) + require.Equal(t, tt.model, got.Models[0].Slug) + require.NotEmpty(t, got.Models[0].ModelMessages) + require.NotEmpty(t, got.Models[0].TruncationPolicy) + require.NotNil(t, got.Models[0].AvailabilityNUX) + require.NotNil(t, got.Models[0].Upgrade) + require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels)) + require.Equal(t, tt.modalities, got.Models[0].InputModalities) + }) + } +} + +// Scenario: Composite manifests aggregate only administrator-configured models. +func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 120 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 3, + Platform: service.PlatformOpenAI, + Status: service.StatusActive, + Schedulable: true, + Credentials: map[string]any{}, + }, + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 122 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ + ID: 1, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"}, + }, + }}, + }, + }) + group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek} + + first := httptest.NewRecorder() + firstContext, _ := gin.CreateTestContext(first) + firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + h.CodexModels(firstContext) + + require.Equal(t, http.StatusOK, first.Code) + etag := first.Header().Get("ETag") + require.NotEmpty(t, etag) + require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag) + + second := httptest.NewRecorder() + secondContext, _ := gin.CreateTestContext(second) + secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + secondContext.Request.Header.Set("If-None-Match", "W/"+etag) + secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + h.CodexModels(secondContext) + + require.Equal(t, http.StatusNotModified, second.Code) + require.Empty(t, second.Body.Bytes()) + require.Equal(t, etag, second.Header().Get("ETag")) +} + +// Scenario: group models_list_config limits the generated Codex manifest. +func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 121 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformComposite, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"grok-4.6"}, + }, + }, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +func codexModelSlugsForTest(models []struct { + Slug string `json:"slug"` + SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"` + InputModalities []string `json:"input_modalities"` + ModelMessages map[string]json.RawMessage `json:"model_messages"` + TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"` + AvailabilityNUX json.RawMessage `json:"availability_nux"` + Upgrade json.RawMessage `json:"upgrade"` +}) []string { + slugs := make([]string, 0, len(models)) + for _, model := range models { + slugs = append(slugs, model.Slug) + } + return slugs +} + +func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string { + efforts := make([]string, 0, len(levels)) + for _, level := range levels { + efforts = append(efforts, level.Effort) + } + return efforts +} + func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) { gin.SetMode(gin.TestMode) @@ -192,6 +454,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) { require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data)) } +// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults. +func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(64) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAnthropic}}, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := codexModelSlugsForTest(got.Models) + require.Contains(t, slugs, "claude-opus-4-6") + require.NotContains(t, slugs, "gemini-2.5-flash") +} + +// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups. +func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(65) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAntigravity}}, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + ids := modelIDsForTest(got.Data) + require.Contains(t, ids, "claude-opus-4-6") + require.Contains(t, ids, "gemini-2.5-flash") +} + func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) { gin.SetMode(gin.TestMode) @@ -321,6 +639,27 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t }, }, }, + { + ID: 4, + Platform: service.PlatformKimi, + Credentials: map[string]any{ + "model_mapping": map[string]any{"kimi-custom": "kimi-upstream"}, + }, + }, + { + ID: 5, + Platform: service.PlatformZhipu, + Credentials: map[string]any{ + "model_mapping": map[string]any{"glm-custom": "glm-upstream"}, + }, + }, + { + ID: 6, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"}, + }, + }, }, }, }, @@ -335,7 +674,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t Platform: service.PlatformComposite, ModelsListConfig: service.GroupModelsListConfig{ Enabled: true, - Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5"}, + Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, }, }, }) @@ -346,7 +685,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t var got gatewayModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5"}, modelIDsForTest(got.Data)) + require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data)) } func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) { @@ -385,6 +724,139 @@ func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t require.NotContains(t, ids, "gemini-2.5-flash") } +// CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把 +// defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。 +func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(35) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + {ID: 1, Platform: service.PlatformOpenAI}, + {ID: 2, Platform: service.PlatformKimi}, + {ID: 3, Platform: service.PlatformZhipu}, + {ID: 4, Platform: service.PlatformDeepseek}, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + + ids := modelIDsForTest(got.Data) + require.Contains(t, ids, "gpt-5.5") + require.NotContains(t, ids, "claude-sonnet-4-6") +} + +// 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的 +// 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。 +func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) { + want := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + want = append(want, model.ID) + } + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { + require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform) + } +} + +func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) { + require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek)) + require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic)) +} + +func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 130 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformDeepseek, + Status: service.StatusActive, + Schedulable: true, + Credentials: map[string]any{}, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + slugs = append(slugs, model.Slug) + } + require.Contains(t, slugs, "deepseek-v4-pro") + require.Contains(t, slugs, "deepseek-v4-flash") + require.NotContains(t, slugs, "claude-sonnet-4-6") + require.NotContains(t, slugs, "claude-opus-4-6") +} + +func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 131 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "foo-*": "deepseek-v4-pro", + "deepseek-v4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + slugs = append(slugs, model.Slug) + } + require.Equal(t, []string{"deepseek-v4-pro"}, slugs) +} + func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 2a270d3a0a..07840b7ea4 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -569,6 +569,13 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) + // 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。 + var longContextThreshold int + var longContextMultiplier float64 + if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil { + longContextThreshold = rule.Threshold + longContextMultiplier = rule.Multiplier + } h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ Result: result, @@ -583,8 +590,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, - LongContextThreshold: 200000, // Gemini 200K 阈值 - LongContextMultiplier: 2.0, // 超出部分双倍计费 + LongContextThreshold: longContextThreshold, + LongContextMultiplier: longContextMultiplier, ForceCacheBilling: forceCacheBilling, APIKeyService: h.apiKeyService, SessionID: sessionID, diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index 4ac1ae53bf..28ae40a39b 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -44,39 +44,84 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { return } - selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability( - c.Request.Context(), - apiKey.GroupID, - "", - "", - "grok-4.5", - nil, - service.OpenAIUpstreamTransportHTTPSSE, - // Grok only advertises chat_completions + media capabilities on HEAD. - service.OpenAIEndpointCapabilityChatCompletions, - false, - false, - false, - service.PlatformGrok, - ) - if err != nil || selection == nil || selection.Account == nil { - h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts") - return - } - - var streamStarted bool reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime") - release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog) - if slotStatus != openAISlotAcquireOK { + model := c.Query("model") + if strings.TrimSpace(model) == "" { + model = "grok-voice-latest" + } + // Keep the HTTP response uncommitted while selecting and probing an account. + // Realtime is not an HTTP streaming response; using reqStream=true here would + // let the wait queue flush an SSE ping before the WebSocket handshake succeeds. + failed := map[int64]struct{}{} + var selection *service.AccountSelectionResult + var release func() + var token string + var upstream *service.GrokRealtimeUpstream + var candidateSeen bool + for attempts := 0; attempts < 4; attempts++ { + // Realtime's voice model is not a text-model capability. Passing a + // concrete text model here would reject accounts mapped only to an + // older/default text model before the upstream handshake can decide. + // An empty requested model keeps account selection capability-based; + // the actual voice model remains in the upstream WS query below. + candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability( + c.Request.Context(), apiKey.GroupID, "", "", "", failed, + service.OpenAIUpstreamTransportHTTPSSE, + service.OpenAIEndpointCapabilityChatCompletions, + false, false, false, service.PlatformGrok, + ) + if selectErr != nil || candidate == nil || candidate.Account == nil { + break + } + candidateSeen = true + account := candidate.Account + var streamStarted bool + var slotStatus openAISlotAcquireResult + release, slotStatus = h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", candidate, false, &streamStarted, reqLog) + if slotStatus != openAISlotAcquireOK { + if slotStatus == openAISlotAcquireFailed { + return + } + failed[account.ID] = struct{}{} + continue + } + var credErr error + token, _, credErr = h.gatewayService.GetRequestCredential(c.Request.Context(), c, account) + if credErr != nil { + release() + release = nil + failed[account.ID] = struct{}{} + continue + } + probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), service.DefaultGrokRealtimeDialTimeout) + candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model) + cancelProbe() + if openErr != nil { + reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr)) + statusCode := http.StatusBadGateway + var dialErr *service.GrokRealtimeDialError + if errors.As(openErr, &dialErr) && dialErr.StatusCode > 0 { + statusCode = dialErr.StatusCode + } + h.gatewayService.HandleGrokRealtimeUpstreamError(c.Request.Context(), account, statusCode, []byte(openErr.Error())) + release() + release = nil + failed[account.ID] = struct{}{} + continue + } + selection, upstream = candidate, candidateUpstream + break + } + if selection == nil || selection.Account == nil || release == nil || upstream == nil { + if !candidateSeen { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts") + } else { + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable") + } return } defer release() - - token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account) - if err != nil { - h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable") - return - } + defer func() { _ = upstream.Close() }() conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) if err != nil { @@ -84,12 +129,8 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { } defer func() { _ = conn.CloseNow() }() - model := c.Query("model") - if strings.TrimSpace(model) == "" { - model = "grok-voice-latest" - } started := time.Now() - audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model) + audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtimeConn(c.Request.Context(), c, conn, upstream) elapsed := time.Since(started) if proxyErr != nil { reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr)) diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 3cef97ed3f..d3fbe9c847 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -346,7 +346,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, nil), false, nil) } if c.Writer.Size() != writerSizeBeforeForward { h.handleFailoverExhausted(c, failoverErr, true) @@ -361,19 +361,21 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) + if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("grok_media.pool_mode_same_account_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_limit", retryLimit), zap.Int("retry_count", sameAccountRetryCount[account.ID]), + zap.Duration("retry_delay", retryDelay), ) select { case <-requestCtx.Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } @@ -398,7 +400,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, nil), false, nil) if !service.IsResponseCommitted(c) && c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") } @@ -409,7 +411,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, result), true, nil) if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" { if err := h.gatewayService.BindGrokMediaVideoRequestAccount( requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID, diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 359aff0071..f7126904db 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -19,6 +19,7 @@ type AdminHandlers struct { GeminiOAuth *admin.GeminiOAuthHandler AntigravityOAuth *admin.AntigravityOAuthHandler GrokOAuth *admin.GrokOAuthHandler + CNProvider *admin.CNProviderHandler Proxy *admin.ProxyHandler Redeem *admin.RedeemHandler Promo *admin.PromoHandler @@ -30,6 +31,7 @@ type AdminHandlers struct { UserAttribute *admin.UserAttributeHandler ErrorPassthrough *admin.ErrorPassthroughHandler TLSFingerprintProfile *admin.TLSFingerprintProfileHandler + Plugin *admin.PluginHandler APIKey *admin.AdminAPIKeyHandler ScheduledTest *admin.ScheduledTestHandler Channel *admin.ChannelHandler diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 0bf294d443..829ef94af4 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -17,39 +17,60 @@ import ( // - 匿名:仅非专属分组(订阅型照常展示); // - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。 type ModelPlazaHandler struct { - channelService *service.ChannelService + plazaService *service.ModelPlazaService apiKeyService *service.APIKeyService settingService *service.SettingService } // NewModelPlazaHandler 创建模型广场 handler。 func NewModelPlazaHandler( - channelService *service.ChannelService, + plazaService *service.ModelPlazaService, apiKeyService *service.APIKeyService, settingService *service.SettingService, ) *ModelPlazaHandler { return &ModelPlazaHandler{ - channelService: channelService, + plazaService: plazaService, apiKeyService: apiKeyService, settingService: settingService, } } -// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。 +// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。 type modelPlazaOfficialPricing struct { InputPrice *float64 `json:"input_price"` OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"` CacheReadPrice *float64 `json:"cache_read_price"` + // Intervals 官方长上下文阶梯,仅多档模型给出。 + Intervals []userPricingIntervalDTO `json:"intervals,omitempty"` } -// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。 +// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。 +type modelPlazaTimePricingPeriod struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Multiplier float64 `json:"multiplier"` +} + +// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。 +// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。 +type modelPlazaTimePricing struct { + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only,omitempty"` + Periods []modelPlazaTimePricingPeriod `json:"periods"` +} + +// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。 type modelPlazaModel struct { Name string `json:"name"` Platform string `json:"platform"` Pricing *userSupportedModelPricing `json:"pricing"` OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"` + // LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。 + LongContextBasis string `json:"long_context_basis,omitempty"` + // TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。 + TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"` } // modelPlazaGroup 广场分组条目(白名单字段)。 @@ -68,9 +89,11 @@ type modelPlazaGroup struct { IsExclusive bool `json:"is_exclusive"` // 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier, // 不取分组/用户专属倍率。 - ImageRateIndependent bool `json:"image_rate_independent"` - ImageRateMultiplier float64 `json:"image_rate_multiplier"` - Models []modelPlazaModel `json:"models"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + // 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。 + LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` + Models []modelPlazaModel `json:"models"` } // modelPlazaResponse 广场页响应。 @@ -98,7 +121,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { return } - groups, err := h.channelService.ListPlazaGroups(c.Request.Context()) + groups, err := h.plazaService.ListGroups(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) return @@ -161,27 +184,30 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo for i := range g.Models { m := &g.Models[i] models = append(models, modelPlazaModel{ - Name: m.Name, - Platform: m.Platform, - Pricing: toUserPricing(m.Pricing), - OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing), + Name: m.Name, + Platform: m.Platform, + Pricing: toUserPricing(m.Pricing), + OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing), + LongContextBasis: string(m.LongContextBasis), + TimePricing: toModelPlazaTimePricing(m.TimePricing), }) } dto := modelPlazaGroup{ - ID: g.ID, - Name: g.Name, - Description: g.Description, - Platform: g.Platform, - SubscriptionType: g.SubscriptionType, - RateMultiplier: g.RateMultiplier, - PeakRateEnabled: g.PeakRateEnabled, - PeakStart: g.PeakStart, - PeakEnd: g.PeakEnd, - PeakRateMultiplier: g.PeakRateMultiplier, - IsExclusive: g.IsExclusive, - ImageRateIndependent: g.ImageRateIndependent, - ImageRateMultiplier: g.ImageRateMultiplier, - Models: models, + ID: g.ID, + Name: g.Name, + Description: g.Description, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, + ImageRateIndependent: g.ImageRateIndependent, + ImageRateMultiplier: g.ImageRateMultiplier, + LongContextPricingEnabled: g.LongContextPricingEnabled, + Models: models, } if rate, ok := userRates[g.ID]; ok { dto.UserRateMultiplier = &rate @@ -189,6 +215,22 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo return dto } +// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。 +func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing { + if p == nil || len(p.Periods) == 0 { + return nil + } + periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods)) + for _, period := range p.Periods { + periods = append(periods, modelPlazaTimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: period.Multiplier, + }) + } + return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods} +} + // toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。 func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing { if p == nil { @@ -200,5 +242,6 @@ func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOff CacheWritePrice: p.CacheWritePrice, CacheWrite1hPrice: p.CacheWrite1hPrice, CacheReadPrice: p.CacheReadPrice, + Intervals: toUserPricingIntervals(p.Intervals), } } diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index a7fc291ab8..ab37a6db24 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -91,7 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { "id", "name", "description", "platform", "subscription_type", "rate_multiplier", "user_rate_multiplier", "is_exclusive", "models", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", - "image_rate_independent", "image_rate_multiplier", + "image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled", } { _, exists := decoded[key] require.Truef(t, exists, "plaza group DTO must expose %q", key) @@ -109,6 +109,12 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { require.Contains(t, official, "cache_read_price") _, has1h := official["cache_write_1h_price"] require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty") + _, hasOfficialIntervals := official["intervals"] + require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty") + _, hasBasis := model["long_context_basis"] + require.False(t, hasBasis, "单档模型不输出 long_context_basis") + _, hasTimePricing := model["time_pricing"] + require.False(t, hasTimePricing, "无分时时不输出 time_pricing") // 无专属倍率:user_rate_multiplier 整个字段省略 dtoNoRate := toModelPlazaGroupDTO(&g, nil) @@ -124,4 +130,94 @@ func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) { require.Nil(t, toModelPlazaOfficialPricing(nil)) } +func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) { + maxTokens := 272000 + g := service.PlazaGroup{ + ID: 3, Name: "ladder", Platform: "openai", SubscriptionType: "standard", RateMultiplier: 1, + LongContextPricingEnabled: true, + Models: []service.PlazaModel{{ + Name: "gpt-5.4", + Platform: "openai", + Pricing: &service.ChannelModelPricing{ + BillingMode: service.BillingModeToken, + InputPrice: testPtr(2.5e-6), + Intervals: []service.PricingInterval{ + {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)}, + {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)}, + }, + }, + OfficialPricing: &service.PlazaOfficialPricing{ + InputPrice: testPtr(2.5e-6), + Intervals: []service.PricingInterval{ + {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)}, + {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)}, + }, + }, + LongContextBasis: service.ContextPricingBasisWholeRequest, + }}, + } + + raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil)) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Equal(t, true, decoded["long_context_pricing_enabled"]) + + model := decoded["models"].([]any)[0].(map[string]any) + require.Equal(t, "whole_request", model["long_context_basis"]) + + pricing := model["pricing"].(map[string]any) + paidTiers := pricing["intervals"].([]any) + require.Len(t, paidTiers, 2) + require.Equal(t, ">272K", paidTiers[1].(map[string]any)["tier_label"]) + + official := model["official_pricing"].(map[string]any) + officialTiers := official["intervals"].([]any) + require.Len(t, officialTiers, 2) + first := officialTiers[0].(map[string]any) + require.Equal(t, "≤272K", first["tier_label"]) + require.InDelta(t, 272000, first["max_tokens"].(float64), 0) + require.Contains(t, first, "cache_write_price", "区间 DTO 字段齐全(nil 输出 null)") +} + func testPtr(v float64) *float64 { return &v } + +func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { + g := service.PlazaGroup{ + ID: 4, Name: "cn", Platform: "deepseek", SubscriptionType: "standard", RateMultiplier: 1, + Models: []service.PlazaModel{{ + Name: "deepseek-chat", + Platform: "deepseek", + Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.28e-6)}, + TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }, { + Name: "deepseek-reasoner", + Platform: "deepseek", + Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.56e-6)}, + TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []service.TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }}, + } + raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil)) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + model := decoded["models"].([]any)[0].(map[string]any) + tp := model["time_pricing"].(map[string]any) + require.Equal(t, "Asia/Shanghai", tp["timezone"]) + _, hasWeekdaysOnly := tp["weekdays_only"] + require.False(t, hasWeekdaysOnly, "未开启仅工作日时字段省略") + periods := tp["periods"].([]any) + require.Len(t, periods, 1) + first := periods[0].(map[string]any) + require.Equal(t, "00:30", first["start_time"]) + require.Equal(t, "08:30", first["end_time"]) + require.InDelta(t, 0.5, first["multiplier"].(float64), 1e-12) + + weekdaysModel := decoded["models"].([]any)[1].(map[string]any) + weekdaysTP := weekdaysModel["time_pricing"].(map[string]any) + require.Equal(t, true, weekdaysTP["weekdays_only"]) +} diff --git a/backend/internal/handler/no_account_error.go b/backend/internal/handler/no_account_error.go index 8982c25b40..6241ae7932 100644 --- a/backend/internal/handler/no_account_error.go +++ b/backend/internal/handler/no_account_error.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "regexp" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -32,6 +34,29 @@ type noAccountErrorClassification struct { ModelNotFound bool // true when this is a 404 model_not_found classification } +var selectionModelRateLimitedPattern = regexp.MustCompile(`(?:model_rate_limited|rate_limited)=(\d+)`) + +// classifySelectionFailureError preserves the scheduler's compact reason when +// every model-capable account is temporarily rate limited. +func classifySelectionFailureError(err error, fallback noAccountErrorClassification) noAccountErrorClassification { + if err == nil { + return fallback + } + match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error())) + if len(match) != 2 { + return fallback + } + count, parseErr := strconv.Atoi(match[1]) + if parseErr != nil || count <= 0 { + return fallback + } + return noAccountErrorClassification{ + Status: http.StatusTooManyRequests, + ErrType: "rate_limit_error", + Message: "All available accounts are currently rate-limited. Please retry later.", + } +} + // classifyNoAccountError decides between 404 model_not_found and 503 // api_error for "no available accounts" failures. // @@ -106,7 +131,11 @@ func classifyNoAccountErrorFromGin( if c != nil && c.Request != nil { ctx = c.Request.Context() } - return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform) + classification := classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform) + if classification.ModelNotFound { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + } + return classification } func classifyOpenAICompatibleNoAccountErrorFromGin( diff --git a/backend/internal/handler/no_account_error_test.go b/backend/internal/handler/no_account_error_test.go index 8c6784e84d..759e610f89 100644 --- a/backend/internal/handler/no_account_error_test.go +++ b/backend/internal/handler/no_account_error_test.go @@ -61,6 +61,21 @@ func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) { require.False(t, cls.ModelNotFound) } +func TestClassifySelectionFailureError_RateLimitedPool(t *testing.T) { + fallback := noAccountErrorClassification{Status: http.StatusServiceUnavailable, ErrType: "api_error", Message: "Service temporarily unavailable"} + + got := classifySelectionFailureError( + fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"), + fallback, + ) + + require.Equal(t, http.StatusTooManyRequests, got.Status) + require.Equal(t, "rate_limit_error", got.ErrType) + require.Contains(t, got.Message, "rate-limited") + require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("model_rate_limited=0"), fallback)) + require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("no available accounts"), fallback)) +} + func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} @@ -113,6 +128,8 @@ func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) { require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform) require.NotNil(t, fd.calls[0].GroupID) require.Equal(t, int64(42), *fd.calls[0].GroupID) + require.True(t, service.HasOpsClientBusinessLimited(c)) + require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c)) } func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) { @@ -134,6 +151,8 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing. require.True(t, cls.ModelNotFound) require.Len(t, fd.calls, 1) require.Equal(t, service.PlatformGrok, fd.calls[0].Platform) + require.True(t, service.HasOpsClientBusinessLimited(c)) + require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c)) logErr := openAICompatibleSelectionErrorForLog( fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"), @@ -142,6 +161,18 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing. require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5") } +func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) { + c := newTestGinContextWithRequest() + fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} + apiKey := &service.APIKey{GroupID: ptrInt64(7)} + + cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) + + require.True(t, cls.ModelNotFound) + require.False(t, service.HasOpsClientBusinessLimited(c)) + require.Empty(t, service.OpsClientBusinessLimitedReason(c)) +} + func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}} @@ -200,4 +231,6 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) { require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response") require.True(t, cls.ModelNotFound) + require.False(t, service.HasOpsClientBusinessLimited(nil)) + require.Empty(t, service.OpsClientBusinessLimitedReason(nil)) } diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index c5766e5669..3846c93a7f 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -30,8 +30,8 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return } - if apiKey.Group.Platform != service.PlatformOpenAI { - h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI groups") + if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI and Composite groups") return } subject, ok := middleware2.GetAuthSubjectFromContext(c) @@ -75,6 +75,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { return } requestedModel := strings.TrimSpace(modelResult.String()) + if !compositeTargetPlatformAllowed(c, apiKey, requestedModel, service.PlatformOpenAI) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search only supports OpenAI models for Composite groups") + return + } reqLog = reqLog.With(zap.String("model", requestedModel)) setOpsRequestContext(c, requestedModel, false) setOpsEndpointContext(c, "", int16(service.RequestTypeSync)) @@ -109,6 +113,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID) profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) + sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError switchCount := 0 var oauth429FailoverState service.OpenAIOAuth429FailoverState @@ -182,7 +187,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds()) if err == nil { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), true, nil) if result != nil { h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID) } @@ -191,7 +196,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { var failoverErr *service.UpstreamFailoverError if !errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err) if c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") } @@ -199,7 +204,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err) if c.Writer.Size() != writerSizeBeforeForward { h.handleFailoverExhausted(c, failoverErr, true) return @@ -211,6 +216,26 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { ) return } + if failoverErr.RetryableOnSameAccount { + retryLimit := account.GetPoolModeRetryCount() + if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { + sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) + reqLog.Warn("openai_alpha_search.same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_limit", retryLimit), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + zap.Duration("retry_delay", retryDelay), + ) + select { + case <-c.Request.Context().Done(): + return + case <-time.After(retryDelay): + } + continue + } + } h.gatewayService.RecordOpenAIAccountSwitch() failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 42dc13112a..aa2b610d70 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -55,6 +55,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) return } + logRequestBodyReadFailure(reqLog, c.Request, err) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") return } @@ -88,6 +89,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if _, err := service.ValidateOpenAIServiceTierField(body); err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } if service.IsGPTImageGenerationModel(reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint") return @@ -182,6 +187,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { ) if len(failedAccountIDs) == 0 { cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel) + cls = classifySelectionFailureError(err, cls) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -239,11 +245,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { }() return h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "") }() - cyberBlockKeyChat := "" + var cyberBlockBodyChat []byte if service.GetOpsCyberPolicy(c) != nil { - cyberBlockKeyChat = service.CyberSessionBlockKey(apiKey.ID, c, body) + cyberBlockBodyChat = body } - h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyChat, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body)) + h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockBodyChat, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body)) forwardDurationMs := time.Since(forwardStart).Milliseconds() upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey) @@ -255,6 +261,48 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { if err == nil && result != nil && result.FirstTokenMs != nil { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } + // #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常 + // 入账;failover 错误恒定 result=nil,不会重复计费。 + submitChatUsage := func(res *service.OpenAIForwardResult) { + if res == nil { + return + } + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) + cyberBlocked := service.GetOpsCyberPolicy(c) != nil + h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ + Result: res, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + SessionID: sessionID, + ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel), + PricingAt: pricingAt, + CyberBlocked: cyberBlocked, + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.chat_completions"), + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("model", reqModel), + zap.Int64("account_id", account.ID), + ).Error("openai_chat_completions.record_usage_failed", zap.Error(err)) + } + }) + } if err != nil { if result != nil && result.ImageCount > 0 { reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result", @@ -273,11 +321,12 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { return } if c.Writer.Size() != writerSizeBeforeForward { + h.gatewayService.ObserveOpenAIAccountHealthFailure(c.Request.Context(), account, err) h.handleFailoverExhausted(c, failoverErr, true) return } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, nil), false, nil, err) } if !failoverErr.ShouldRetryNextAccount() { h.handleFailoverExhausted(c, failoverErr, streamStarted) @@ -285,8 +334,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { } // Pool mode: retry on the same account if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) + if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry", @@ -324,7 +373,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, nil), false, nil, err) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { @@ -339,51 +388,17 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated), zap.Error(err), ) + submitChatUsage(result) return } } if result != nil { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, nil) } - userAgent := c.GetHeader("User-Agent") - clientIP := ip.GetClientIP(c) - inboundEndpoint := GetInboundEndpoint(c) - upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) - quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) - sessionID := service.ExtractClientSessionID(c) - - cyberBlocked := service.GetOpsCyberPolicy(c) != nil - h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { - if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ - Result: result, - APIKey: apiKey, - User: apiKey.User, - Account: account, - Subscription: subscription, - InboundEndpoint: inboundEndpoint, - UpstreamEndpoint: upstreamEndpoint, - UserAgent: userAgent, - IPAddress: clientIP, - APIKeyService: h.apiKeyService, - QuotaPlatform: quotaPlatform, - SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), - PricingAt: pricingAt, - CyberBlocked: cyberBlocked, - }); err != nil { - logger.L().With( - zap.String("component", "handler.openai_gateway.chat_completions"), - zap.Int64("user_id", subject.UserID), - zap.Int64("api_key_id", apiKey.ID), - zap.Any("group_id", apiKey.GroupID), - zap.String("model", reqModel), - zap.Int64("account_id", account.ID), - ).Error("openai_chat_completions.record_usage_failed", zap.Error(err)) - } - }) + submitChatUsage(result) reqLog.Debug("openai_chat_completions.request_completed", zap.Int64("account_id", account.ID), zap.Int("switch_count", switchCount), diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 690cba9ce6..3a38ce5154 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -15,9 +15,9 @@ import ( // Codex CLI and the Codex desktop app refresh their model picker from // GET {base_url}/models?client_version=... (custom provider mode) or // GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land -// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive -// provider-compatibility normalization and use a short-lived, asynchronously -// revalidated cache to tolerate canceled client requests. +// here. Groups with explicit account model mappings are generated locally; +// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests +// receive provider-compatibility normalization plus short-lived caching. func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { if c.Request.Context().Err() != nil { return @@ -27,8 +27,26 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") return } - if apiKey.Group.Platform != service.PlatformOpenAI { - h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups") + if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI and Composite groups") + return + } + + ifNoneMatch := c.GetHeader("If-None-Match") + configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest( + c.Request.Context(), + apiKey.Group, + ifNoneMatch, + ) + if err != nil { + if c.Request.Context().Err() != nil { + return + } + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + if configured { + writeCodexModelsManifestResponse(c, configuredManifest) return } @@ -56,7 +74,9 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { // 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。 setOpsSelectedAccount(c, account.ID, account.Platform) - manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match")) + // The client ETag represents the final group-specific body, so fetch the + // source manifest before applying local filtering and alias metadata. + manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "") if err != nil { if c.Request.Context().Err() != nil { return @@ -70,18 +90,31 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err)) return } + if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest") + return + } + if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } if c.Request.Context().Err() != nil { return } - if manifest.ETag != "" { - c.Header("ETag", manifest.ETag) - } - if manifest.NotModified { - c.Status(http.StatusNotModified) - return - } - c.Data(http.StatusOK, "application/json", manifest.Body) + writeCodexModelsManifestResponse(c, manifest) return } } + +func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) { + if manifest.ETag != "" { + c.Header("ETag", manifest.ETag) + } + if manifest.NotModified { + c.Status(http.StatusNotModified) + c.Writer.WriteHeaderNow() + return + } + c.Data(http.StatusOK, "application/json", manifest.Body) +} diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 7636fa5fd4..9eabd3e5b2 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -16,6 +17,7 @@ import ( middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" ) type codexModelsFailoverAccountRepo struct { @@ -43,6 +45,14 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont return accounts, nil } +func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) { + return append([]service.Account(nil), r.accounts...), nil +} + +func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) ([]service.Account, error) { + return append([]service.Account(nil), r.accounts...), nil +} + type codexModelsFailoverHTTPUpstream struct { service.HTTPUpstream mu sync.Mutex @@ -116,6 +126,249 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) { } } +func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(43) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "custom-openai", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{ + firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`, + } + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"codex-auto-review", "gpt-5.6"}, + }, + } + + first := performCodexModelsRequestForGroup(t, handler, group, "") + if first.Code != http.StatusOK { + t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String()) + } + if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") { + t.Fatalf("first body did not include the explicitly selected models: %s", body) + } + oldETag := first.Header().Get("ETag") + if oldETag == "" { + t.Fatal("first response did not include an ETag") + } + + group.ModelsListConfig.Enabled = false + second := performCodexModelsRequestForGroup(t, handler, group, oldETag) + if second.Code != http.StatusOK { + t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String()) + } + if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") { + t.Fatalf("second body was not the filtered manifest: %s", body) + } + if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag { + t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag) + } + + third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag")) + if third.Code != http.StatusNotModified { + t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String()) + } + if third.Body.Len() != 0 { + t.Fatalf("third body: got %q, want empty", third.Body.String()) + } +} + +func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "shared-api-key", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-shared", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{ + firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`, + } + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + groupA := &service.Group{ + ID: 91, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-a"}, + }, + } + groupB := &service.Group{ + ID: 92, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + } + + firstA := performCodexModelsRequestForGroup(t, handler, groupA, "") + require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String()) + require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA)) + + firstB := performCodexModelsRequestForGroup(t, handler, groupB, "") + require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String()) + require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB)) + + etagA := firstA.Header().Get("ETag") + require.NotEmpty(t, etagA) + + var wg sync.WaitGroup + results := make([]*httptest.ResponseRecorder, 8) + for i := range results { + wg.Add(1) + go func(index int) { + defer wg.Done() + if index%2 == 0 { + results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA) + return + } + results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "") + }(i) + } + wg.Wait() + + sawGroupB := false + for _, recorder := range results { + require.NotNil(t, recorder) + switch recorder.Code { + case http.StatusNotModified: + require.Empty(t, recorder.Body.Bytes()) + case http.StatusOK: + slugs := codexHandlerManifestSlugs(t, recorder) + if len(slugs) == 1 && slugs[0] == "model-b" { + sawGroupB = true + continue + } + require.Equal(t, []string{"model-a"}, slugs) + default: + t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String()) + } + } + require.True(t, sawGroupB) +} + +// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 +func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(44) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "ark-compatible", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Priority: 0, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-ark", + "base_url": "https://ark.example/v1", + "model_mapping": map[string]any{ + "glm-5.3": "glm-5.3", + }, + }, + }, + { + ID: 2, + Name: "chatgpt-oauth", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: true, + Priority: 1, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-test", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + + recorder := performCodexModelsRequestForGroup(t, handler, &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, "") + + if got := upstream.calls(); len(got) != 0 { + t.Fatalf("upstream account calls: got %v, want none", got) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + var envelope struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" { + t.Fatalf("models: got %v, want only glm-5.3", envelope.Models) + } + if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok { + t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0]) + } +} + +func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { + handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable) + + recorder := performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformComposite) + + if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) { + t.Fatalf("upstream account calls: got %v, want %v", got, want) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } +} + func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) { retryableStatuses := []int{ http.StatusTooManyRequests, @@ -135,8 +388,23 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } - if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want { - t.Fatalf("body: got %q, want %q", got, want) + requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol") + }) + } +} + +// Scenario: an API-key upstream without /models is excluded only for this discovery request. +func TestCodexModelsFailsOverWhenAPIKeyModelsEndpointIsUnavailable(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} { + t.Run(http.StatusText(status), func(t *testing.T) { + handler, upstream, groupID := newCodexModelsFailoverTestHandler(status) + recorder := performCodexModelsRequest(t, handler, groupID) + + if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) { + t.Fatalf("upstream account calls: got %v, want %v", got, want) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } }) } @@ -170,9 +438,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } - if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want { - t.Fatalf("body: got %q, want %q", got, want) - } + requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol") } func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) { @@ -180,7 +446,6 @@ func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) { http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, - http.StatusNotFound, 600, } for _, status := range statuses { @@ -287,19 +552,79 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount } func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder { + return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "") +} + +func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder { + return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "") +} + +func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder { t.Helper() recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil) + if etag != "" { + c.Request.Header.Set("If-None-Match", etag) + } c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ - GroupID: &groupID, - Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, + GroupID: &group.ID, + Group: group, }) handler.CodexModels(c) return recorder } +func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string { + t.Helper() + + var envelope struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + slugs := make([]string, 0, len(envelope.Models)) + for _, model := range envelope.Models { + slugs = append(slugs, model.Slug) + } + return slugs +} + +func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) { + t.Helper() + + var envelope struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + if len(envelope.Models) != 1 { + t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String()) + } + model := envelope.Models[0] + if got := model["slug"]; got != slug { + t.Fatalf("slug: got %v, want %q", got, slug) + } + if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 { + t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"]) + } + if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" { + t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"]) + } + if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 { + t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"]) + } + modalities, ok := model["input_modalities"].([]any) + if !ok || len(modalities) != 1 || modalities[0] != "text" { + t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"]) + } +} + func equalInt64Slices(got, want []int64) bool { if len(got) != len(want) { return false diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 92008c59c2..913131114e 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -215,7 +215,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { h.handleFailoverExhausted(c, failoverErr, true) return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), false, nil, err) if failoverClientGone(c) { reqLog.Info("openai_embeddings.failover_aborted_client_disconnected", zap.Int64("account_id", account.ID), @@ -239,7 +239,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), false, nil, err) if c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") } @@ -250,7 +250,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, reqModel, false, result), true, nil) userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) inboundEndpoint := GetInboundEndpoint(c) diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go new file mode 100644 index 0000000000..eefb0948cb --- /dev/null +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -0,0 +1,77 @@ +package handler + +// CN 分组 /v1/messages 调度闸门回归(修复:正常途径创建的 CN 分组曾恒 403): +// sanitizeGroupMessagesDispatchFields 对非 openai/composite 平台强制 AllowMessagesDispatch +// =false,故 CN 分组必须与 grok 一样在闸门处豁免,否则原生 Anthropic 直通 +//(Claude Code 主用例)永远不可达。composite 分组解析到 grok/CN 目标时按 +// 目标平台豁免,解析到 openai 目标仍受其可配置开关控制。 + +import ( + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) { + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行") + + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} { + apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}} + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey), + "%s 分组必须豁免 allow_messages_dispatch 闸门", platform) + } + + // 非回归:openai 分组仍受开关控制。 + openaiOff := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: false}} + require.False(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOff)) + openaiOn := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: true}} + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOn)) +} + +func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testing.T) { + gin.SetMode(gin.TestMode) + + newCompositeCtx := func(model string, allow bool) (*gin.Context, *service.APIKey) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: allow}} + ensureCompositeTargetPlatform(c, apiKey, model) + return c, apiKey + } + + // 解析到 grok/CN 目标:与对应独立分组同语义豁免。 + for _, model := range []string{"grok-4.3", "kimi-k2-thinking", "glm-5.2", "deepseek-v3.2"} { + c, apiKey := newCompositeCtx(model, false) + require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey), "model=%s", model) + } + + // 解析到 openai 目标:受 composite 分组自身开关控制。 + c, apiKey := newCompositeCtx("gpt-5.5", false) + require.False(t, allowOpenAICompatibleMessagesDispatch(c, apiKey)) + c, apiKey = newCompositeCtx("gpt-5.5", true) + require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey)) + + // 未解析出目标平台:保持拒绝,不放宽。 + cNone, _ := gin.CreateTestContext(httptest.NewRecorder()) + cNone.Request = httptest.NewRequest("POST", "/v1/messages", nil) + require.False(t, allowOpenAICompatibleMessagesDispatch(cNone, + &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: false}})) +} + +// composite 解析到 grok/CN 目标时,Group 级调度映射(gpt-5.x 默认值为 openai +// 专属)不得注入,模型改写完全交给账号级 model_mapping。 +func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMapping(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, model := range []string{"kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "grok-4.3"} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} + ensureCompositeTargetPlatform(c, apiKey, model) + + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model) + } +} diff --git a/backend/internal/handler/openai_gateway_compact_body_signal_test.go b/backend/internal/handler/openai_gateway_compact_body_signal_test.go index a44d47c856..370abaeba5 100644 --- a/backend/internal/handler/openai_gateway_compact_body_signal_test.go +++ b/backend/internal/handler/openai_gateway_compact_body_signal_test.go @@ -25,54 +25,211 @@ func newCompactBodySignalTestContext(t *testing.T, path string, body []byte) *gi func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2StaysOnResponses(t *testing.T) { h := &OpenAIGatewayHandler{} - body := []byte(`{ - "model":"gpt-5.6-sol", - "stream":true, - "store":true, - "prompt_cache_key":"pck-signal-1", - "reasoning":{"effort":"max","context":"all_turns"}, - "input":[ - {"type":"message","role":"user","content":"hello"}, - {"type":"compaction_trigger"} - ] - }`) - c := newCompactBodySignalTestContext(t, "/v1/responses", body) - c.Request.Header.Set("x-codex-beta-features", "responses_websockets_v2, remote_compaction_v2, another_feature") + tests := []struct { + name string + betaHeader string + userAgent string + }{ + {name: "headerless"}, + {name: "unrelated_header", betaHeader: "responses_websockets_v2"}, + {name: "wrong_case_header", betaHeader: "REMOTE_COMPACTION_V2"}, + {name: "declared_header", betaHeader: "remote_compaction_v2"}, + {name: "codex_cli_user_agent", userAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color"}, + {name: "codex_desktop_user_agent", userAgent: "Codex Desktop/0.139.0 (Mac OS X 14; arm64) unknown"}, + } - normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) - require.True(t, ok) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":true, + "store":true, + "prompt_cache_key":"pck-signal-1", + "reasoning":{"effort":"max","context":"all_turns"}, + "input":[ + {"type":"message","role":"user","content":"hello"}, + {"type":"compaction_trigger"} + ] + }`) + c := newCompactBodySignalTestContext(t, "/v1/responses", body) + if tt.betaHeader != "" { + c.Request.Header.Set("x-codex-beta-features", tt.betaHeader) + } + if tt.userAgent != "" { + c.Request.Header.Set("User-Agent", tt.userAgent) + } - require.Equal(t, "/v1/responses", c.Request.URL.Path) - require.False(t, isOpenAIRemoteCompactPath(c)) - require.Equal(t, body, normalized) - require.True(t, gjson.GetBytes(normalized, "stream").Bool()) - require.True(t, gjson.GetBytes(normalized, "store").Bool()) - require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String()) - require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) - require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String()) + normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) + require.True(t, ok) - reqStream, streamOK := parseOpenAICompatibleStream(normalized) - require.True(t, streamOK) - require.True(t, reqStream) + require.Equal(t, "/v1/responses", c.Request.URL.Path) + require.False(t, isOpenAILegacyCompactPath(c)) + require.Equal(t, body, normalized) + require.True(t, gjson.GetBytes(normalized, "stream").Bool()) + require.True(t, gjson.GetBytes(normalized, "store").Bool()) + require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String()) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String()) + legacyCompact := service.IsOpenAIResponsesCompactPath(c) + nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized) + require.False(t, legacyCompact) + require.True(t, nativeV2) + require.Equal(t, service.OpenAIEndpointCapabilityResponses, + openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI)) - _, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest()) - require.False(t, seedExists) - _, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest()) - require.False(t, streamMarkerExists) + reqStream, streamOK := parseOpenAICompatibleStream(normalized) + require.True(t, streamOK) + require.True(t, reqStream) + + _, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest()) + require.False(t, seedExists) + _, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest()) + require.False(t, streamMarkerExists) + }) + } } func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2PathAliasesStayOnResponses(t *testing.T) { h := &OpenAIGatewayHandler{} body := []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`) - for _, path := range []string{"/v1/responses/", "/backend-api/codex/responses"} { + for _, path := range []string{ + "/v1/responses/", + "/openai/v1/responses", + "/responses", + "/backend-api/codex/responses", + } { t.Run(path, func(t *testing.T) { c := newCompactBodySignalTestContext(t, path, body) - c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) require.True(t, ok) require.Equal(t, path, c.Request.URL.Path) require.Equal(t, body, normalized) + legacyCompact := service.IsOpenAIResponsesCompactPath(c) + nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized) + require.False(t, legacyCompact) + require.True(t, nativeV2) + require.Equal(t, service.OpenAIEndpointCapabilityResponses, + openAIResponsesRequiredCapabilityForRequest(false, nativeV2 || legacyCompact, service.PlatformOpenAI)) + }) + } +} + +func TestOpenAIResponsesCompactionRoutingFlags(t *testing.T) { + h := &OpenAIGatewayHandler{} + tests := []struct { + name string + body []byte + path string + wantLegacyBefore bool + wantNativeBefore bool + wantLegacyAfter bool + wantNativeAfter bool + wantCapabilityAfter service.OpenAIEndpointCapability + wantPathAfter string + wantBodyUnchanged bool + }{ + { + name: "native_v2_stream_trigger", + body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`), + path: "/v1/responses", + wantLegacyBefore: false, + wantNativeBefore: true, + wantLegacyAfter: false, + wantNativeAfter: true, + wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses, + wantPathAfter: "/v1/responses", + wantBodyUnchanged: true, + }, + { + name: "native_v2_without_trigger", + body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`), + path: "/v1/responses", + wantLegacyBefore: false, + wantNativeBefore: false, + wantLegacyAfter: false, + wantNativeAfter: false, + wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions, + wantPathAfter: "/v1/responses", + wantBodyUnchanged: true, + }, + { + name: "explicit_compact", + body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`), + path: "/v1/responses/compact", + wantLegacyBefore: true, + wantNativeBefore: false, + wantLegacyAfter: true, + wantNativeAfter: false, + wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses, + wantPathAfter: "/v1/responses/compact", + }, + { + name: "nested_compact", + body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`), + path: "/v1/responses/compact/detail", + wantLegacyBefore: true, + wantNativeBefore: false, + wantLegacyAfter: true, + wantNativeAfter: false, + wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses, + wantPathAfter: "/v1/responses/compact/detail", + }, + { + name: "responses_subpath_with_native_signal", + body: []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`), + path: "/v1/responses/resp_123/responses", + wantLegacyBefore: false, + wantNativeBefore: false, + wantLegacyAfter: false, + wantNativeAfter: false, + wantCapabilityAfter: service.OpenAIEndpointCapabilityChatCompletions, + wantPathAfter: "/v1/responses/resp_123/responses", + wantBodyUnchanged: true, + }, + { + name: "stream_false_promotes", + body: []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"type":"compaction_trigger"}]}`), + path: "/v1/responses", + wantLegacyBefore: false, + wantNativeBefore: false, + wantLegacyAfter: true, + wantNativeAfter: false, + wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses, + wantPathAfter: "/v1/responses/compact", + }, + { + name: "stream_absent_promotes", + body: []byte(`{"model":"gpt-5.6-sol","input":[{"type":"compaction_trigger"}]}`), + path: "/v1/responses", + wantLegacyBefore: false, + wantNativeBefore: false, + wantLegacyAfter: true, + wantNativeAfter: false, + wantCapabilityAfter: service.OpenAIEndpointCapabilityResponses, + wantPathAfter: "/v1/responses/compact", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newCompactBodySignalTestContext(t, tt.path, tt.body) + legacyBefore := service.IsOpenAIResponsesCompactPath(c) + nativeBefore := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(tt.body) + require.Equal(t, tt.wantLegacyBefore, legacyBefore) + require.Equal(t, tt.wantNativeBefore, nativeBefore) + normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body) + require.True(t, ok) + require.Equal(t, tt.wantPathAfter, c.Request.URL.Path) + legacyAfter := service.IsOpenAIResponsesCompactPath(c) + nativeAfter := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(normalized) + require.Equal(t, tt.wantLegacyAfter, legacyAfter) + require.Equal(t, tt.wantNativeAfter, nativeAfter) + require.Equal(t, tt.wantCapabilityAfter, + openAIResponsesRequiredCapabilityForRequest(false, nativeAfter || legacyAfter, service.PlatformOpenAI)) + if tt.wantBodyUnchanged { + require.Equal(t, tt.body, normalized) + } }) } } @@ -106,31 +263,22 @@ func TestNormalizeOpenAIResponsesCompactRequest_NonRemoteV2BodySignalPromoted(t wantMarked bool }{ { - name: "no_header", - body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`), - wantMarked: true, + name: "stream_false_headerless", + body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`), }, { - name: "unrelated_header", - body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`), - betaHeader: "responses_websockets_v2", - wantMarked: true, + name: "stream_absent_headerless", + body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`), }, { - name: "wrong_case_header", - body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`), - betaHeader: "REMOTE_COMPACTION_V2", - wantMarked: true, - }, - { - name: "stream_false", + name: "stream_false_declared_header", body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`), betaHeader: "remote_compaction_v2", }, { - name: "stream_absent", + name: "stream_absent_wrong_case_header", body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`), - betaHeader: "remote_compaction_v2", + betaHeader: "REMOTE_COMPACTION_V2", }, } @@ -163,7 +311,7 @@ func TestNormalizeOpenAIResponsesCompactRequest_NoTriggerUntouched(t *testing.T) normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) require.True(t, ok) require.Equal(t, "/v1/responses", c.Request.URL.Path) - require.False(t, isOpenAIRemoteCompactPath(c)) + require.False(t, isOpenAILegacyCompactPath(c)) require.Equal(t, body, normalized) require.True(t, gjson.GetBytes(normalized, "stream").Bool()) } @@ -172,7 +320,6 @@ func TestNormalizeOpenAIResponsesCompactRequest_PathBasedNoDoubleSuffix(t *testi h := &OpenAIGatewayHandler{} body := []byte(`{"model":"gpt-5.5","stream":true,"store":true,"input":[{"type":"message","role":"user","content":"hello"}]}`) c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body) - c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) require.True(t, ok) diff --git a/backend/internal/handler/openai_gateway_compact_log_test.go b/backend/internal/handler/openai_gateway_compact_log_test.go index 16afd30c31..0d1140366c 100644 --- a/backend/internal/handler/openai_gateway_compact_log_test.go +++ b/backend/internal/handler/openai_gateway_compact_log_test.go @@ -105,21 +105,29 @@ func captureHandlerStructuredLog(t *testing.T) (*handlerInMemoryLogSink, func()) } } -func TestIsOpenAIRemoteCompactPath(t *testing.T) { - require.False(t, isOpenAIRemoteCompactPath(nil)) +func TestIsOpenAILegacyCompactPath(t *testing.T) { + require.False(t, isOpenAILegacyCompactPath(nil)) gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil) - require.True(t, isOpenAIRemoteCompactPath(c)) - - c.Request = httptest.NewRequest(http.MethodPost, "/responses/compact/", nil) - require.True(t, isOpenAIRemoteCompactPath(c)) - - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - require.False(t, isOpenAIRemoteCompactPath(c)) + for _, test := range []struct { + path string + want bool + }{ + {path: "/v1/responses/compact", want: true}, + {path: "/v1/responses/compact/detail", want: true}, + {path: "/responses/compact/", want: true}, + {path: "/v1/responses", want: false}, + {path: "/openai/v1/responses", want: false}, + {path: "/responses", want: false}, + {path: "/backend-api/codex/responses", want: false}, + {path: "/v1/responses/resp_123/cancel", want: false}, + } { + c.Request = httptest.NewRequest(http.MethodPost, test.path, nil) + require.Equal(t, test.want, isOpenAILegacyCompactPath(c), test.path) + } } func TestLogOpenAIRemoteCompactOutcome_Succeeded(t *testing.T) { diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index ac6deda4aa..2df4f4f2d0 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/domain" @@ -10,9 +11,124 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" "go.uber.org/zap" ) +// ResponsesInputTokens handles native OpenAI POST +// /v1/responses/input_tokens requests without routing them through the normal +// Responses generation and usage-recording pipeline. +func (h *OpenAIGatewayHandler) ResponsesInputTokens(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + reqLog := requestLogger( + c, + "handler.openai_gateway.responses_input_tokens", + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) + if !h.ensureResponsesDependencies(c, reqLog) { + return + } + + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + if len(body) == 0 || !gjson.ValidBytes(body) { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return + } + modelResult := gjson.GetBytes(body, "model") + if !modelResult.Exists() || modelResult.Type != gjson.String || strings.TrimSpace(modelResult.String()) == "" { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return + } + reqModel := strings.TrimSpace(modelResult.String()) + ensureCompositeTargetPlatform(c, apiKey, reqModel) + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") + return + } + + setOpsRequestContext(c, reqModel, false) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false))) + if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && !decision.AllowNextStage { + h.openAISecurityAuditError(c, decision) + return + } + + subscription, _ := middleware2.GetSubscriptionFromContext(c) + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + reqLog.Info("openai_input_tokens.billing_eligibility_check_failed", zap.Error(err)) + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + routingModel := reqModel + forwardBody := body + if channelMapping.Mapped { + routingModel = channelMapping.MappedModel + forwardBody = h.gatewayService.ReplaceModelInBody(body, routingModel) + } + + // Token counting is not billed, so it must not be excluded by the profit gate. + c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context())) + requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey) + sessionHash := h.gatewayService.GenerateSessionHash(c, body) + requestStart := time.Now() + account, err := h.gatewayService.SelectAccountForTokenCount( + c.Request.Context(), + apiKey.GroupID, + sessionHash, + routingModel, + service.OpenAIEndpointCapabilityChatCompletions, + requestPlatform, + ) + service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) + if err != nil { + reqLog.Warn("openai_input_tokens.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform))) + cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, routingModel, reqModel) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimitedIfNoAvailable(c, err) + } + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + if account == nil { + cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, routingModel, reqModel) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimited(c) + } + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + + setOpsSelectedAccount(c, account.ID, account.Platform) + if err := h.gatewayService.ForwardResponsesInputTokens(c.Request.Context(), c, account, forwardBody); err != nil { + reqLog.Error("openai_input_tokens.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } +} + // GrokCountTokens handles Anthropic-compatible count_tokens requests locally. // The route middleware already authenticates the API key and resolves the // group; this handler intentionally does not select an account or check billing. @@ -56,8 +172,7 @@ func (h *OpenAIGatewayHandler) GrokCountTokens(c *gin.Context) { } // CountTokens handles Anthropic-compatible POST /v1/messages/count_tokens for OpenAI groups. -// It validates billing and routes to an OpenAI token-count bridge without taking concurrency slots -// or recording usage. +// It validates billing and routes to an OpenAI token-count bridge without recording usage. func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { @@ -78,7 +193,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { zap.Any("group_id", apiKey.GroupID), ) - if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch { + if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return @@ -117,12 +232,14 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { reqModel := parsedReq.Model ensureCompositeTargetPlatform(c, apiKey, reqModel) - if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) { + // composite+grok 在路由层已分流到 GrokCountTokens,这里可达的目标平台是 + // openai 与 CN 供应商;CN 账号由 ForwardCountTokensAsAnthropic 本地估算。 + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", parsedReq.Stream)) setOpsRequestContext(c, reqModel, false) @@ -151,18 +268,12 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { if preferredMappedModel != "" { currentRoutingModel = preferredMappedModel } - selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability( + account, err := h.gatewayService.SelectAccountForTokenCount( c.Request.Context(), apiKey.GroupID, - "", sessionHash, currentRoutingModel, - nil, - service.OpenAIUpstreamTransportAny, service.OpenAIEndpointCapabilityChatCompletions, - false, - false, - false, openAICompatibleRequestPlatform(c.Request.Context(), apiKey), ) service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) @@ -176,7 +287,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { h.anthropicErrorResponse(c, cls.Status, cls.ErrType, cls.Message) return } - if selection == nil || selection.Account == nil { + if account == nil { cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel) if !cls.ModelNotFound { markOpsRoutingCapacityLimited(c) @@ -185,11 +296,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { return } - account := selection.Account setOpsSelectedAccount(c, account.ID, account.Platform) - if selection.Acquired && selection.ReleaseFunc != nil { - defer selection.ReleaseFunc() - } forwardBody := mappedBodyForMessages(channelMapping.Mapped, channelMapping.MappedModel) defaultMappedModel := preferredMappedModel diff --git a/backend/internal/handler/openai_gateway_credential_failover_test.go b/backend/internal/handler/openai_gateway_credential_failover_test.go index 046b7ec52a..d94eae7fe9 100644 --- a/backend/internal/handler/openai_gateway_credential_failover_test.go +++ b/backend/internal/handler/openai_gateway_credential_failover_test.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestGatewayChatCredentialStopDoesNotSelectAnotherAccountAndReturnsSafe503(t *testing.T) { @@ -64,6 +65,113 @@ func TestGatewayChatAntigravityCredentialFailureReturnsActionableMessage(t *test require.NotContains(t, strings.ToLower(recorder.Body.String()), "refresh_token") } +func TestOpenAIAccessStateCredentialFailureUsesTypedSafeResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{ + StatusCode: http.StatusForbidden, + Stage: service.GatewayFailureStageAccountAuth, + Scope: service.GatewayFailureScopeAccount, + Reason: service.OpenAIUpstreamAccessStateReason, + NextAccountAction: service.NextAccountRetry, + ClientStatusCode: http.StatusBadGateway, + ClientMessage: "Upstream access is temporarily unavailable, please retry later", + ResponseBody: []byte(`{"error":{"message":"Your workspace is deactivated","token":"must-not-leak"}}`), + }, false) + + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.Contains(t, recorder.Body.String(), "Upstream access is temporarily unavailable") + require.NotContains(t, strings.ToLower(recorder.Body.String()), "deactivated") + require.NotContains(t, recorder.Body.String(), "must-not-leak") +} + +func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testing.T) { + gin.SetMode(gin.TestMode) + message := "Our servers are currently overloaded. Please try again later." + failoverErr := &service.UpstreamFailoverError{ + StatusCode: http.StatusBadRequest, + ResponseBody: []byte(`{"error":{"code":"server_is_overloaded","message":"` + message + `"}}`), + RetryableOnSameAccount: true, + RequestScopedTransient: true, + ClientStatusCode: http.StatusServiceUnavailable, + ClientMessage: message, + } + + t.Run("native_openai", func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, failoverErr, false) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Equal(t, "server_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String()) + require.NotContains(t, recorder.Body.String(), "server_is_overloaded") + }) + + t.Run("responses_compat", func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + (&GatewayHandler{}).handleResponsesFailoverExhausted(c, failoverErr, false) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Equal(t, "server_error", gjson.Get(recorder.Body.String(), "error.code").String()) + require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String()) + }) + + t.Run("anthropic_compat", func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + (&OpenAIGatewayHandler{}).handleAnthropicFailoverExhausted(c, failoverErr, false) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Equal(t, "api_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Equal(t, message, gjson.Get(recorder.Body.String(), "error.message").String()) + }) +} + +func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + official := "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"server_error\",\"message\":\"official failure\"}}}\n\n" + _, err := c.Writer.Write([]byte(official)) + require.NoError(t, err) + service.MarkOpsStreamError(c, "server_error", "official failure", http.StatusBadGateway) + + (&GatewayHandler{}).handleResponsesFailoverExhausted(c, &service.UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":{"message":"fallback failure"}}`), + }, true) + + require.Equal(t, official, recorder.Body.String()) + streamErr, ok := service.GetOpsStreamError(c) + require.True(t, ok) + require.Equal(t, "official failure", streamErr.Message) + + markerRecorder := httptest.NewRecorder() + markerContext, _ := gin.CreateTestContext(markerRecorder) + (&GatewayHandler{}).handleResponsesFailoverExhausted(markerContext, &service.UpstreamFailoverError{ + StatusCode: http.StatusTooManyRequests, + }, true) + require.Contains(t, markerRecorder.Body.String(), "event: response.failed") + require.Equal(t, 1, strings.Count(markerRecorder.Body.String(), "event: response.failed")) + streamErr, ok = service.GetOpsStreamError(markerContext) + require.True(t, ok) + require.Equal(t, http.StatusTooManyRequests, streamErr.IntendedStatus) + require.Equal(t, "rate_limit_error", streamErr.ErrType) + + heartbeatRecorder := httptest.NewRecorder() + heartbeatContext, _ := gin.CreateTestContext(heartbeatRecorder) + heartbeat := ": keepalive\n\n" + written, err := heartbeatRecorder.Write([]byte(heartbeat)) + require.NoError(t, err) + recordGatewayStreamHeartbeat(heartbeatContext, written) + (&GatewayHandler{}).handleResponsesFailoverExhausted(heartbeatContext, &service.UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + }, true) + require.True(t, strings.HasPrefix(heartbeatRecorder.Body.String(), heartbeat)) + require.Equal(t, 1, strings.Count(heartbeatRecorder.Body.String(), "event: response.failed")) +} + func TestGatewayChatInferenceExhaustionRestoresRetryAfter(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -197,7 +305,7 @@ func TestOpsClassificationTreatsCredentialFailureAsAuthNotInference(t *testing.T require.Equal(t, http.StatusForbidden, entry.UpstreamErrors[0].UpstreamStatusCode) } -func TestOpsRecoveredCredentialFailoverUsesAccountAuthAttribution(t *testing.T) { +func TestOpsRecoveredCredentialFailoverDoesNotCreateRequestError(t *testing.T) { setupOpsErrorLogTestQueue(t, 2) gin.SetMode(gin.TestMode) ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) @@ -220,18 +328,75 @@ func TestOpsRecoveredCredentialFailoverUsesAccountAuthAttribution(t *testing.T) require.Equal(t, http.StatusOK, recorder.Code) require.Equal(t, int64(1), OpsErrorLogQueueLength()) job := <-opsErrorLogQueue - require.Equal(t, "account_auth", job.entry.ErrorPhase) - require.Equal(t, "provider", job.entry.ErrorOwner) - require.Equal(t, "gateway", job.entry.ErrorSource) - require.Contains(t, job.entry.ErrorMessage, "Recovered account authentication failure") - require.NotContains(t, job.entry.ErrorMessage, "403") - require.NotContains(t, job.entry.ErrorMessage, "earlier inference failure") - require.NotNil(t, job.entry.UpstreamStatusCode) - require.Zero(t, *job.entry.UpstreamStatusCode) - require.Nil(t, job.entry.UpstreamErrors) + require.Equal(t, http.StatusOK, job.entry.StatusCode) + require.Equal(t, string(service.GatewayFailureStageAccountAuth), job.entry.ErrorPhase) require.NotNil(t, job.entry.UpstreamErrorsJSON) events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON) require.NoError(t, err) require.Len(t, events, 2) - require.Equal(t, http.StatusForbidden, events[0].UpstreamStatusCode) + require.Equal(t, string(service.GatewayFailureStageAccountAuth), events[1].Stage) +} + +func TestOpsWebSocketCredentialFailoverSuccessDoesNotCreateRequestError(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.GET("/openai/v1/responses", func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount), + Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action", + }}) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil) + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusOK, job.entry.StatusCode) + require.Equal(t, string(service.GatewayFailureStageAccountAuth), job.entry.ErrorPhase) + require.NotNil(t, job.entry.UpstreamErrorsJSON) + events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, string(service.GatewayFailureStageAccountAuth), events[0].Stage) +} + +func TestOpsWebSocketCredentialFailoverExhaustedIsRecorded(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.GET("/openai/v1/responses", func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Stage: string(service.GatewayFailureStageAccountAuth), Scope: string(service.GatewayFailureScopeAccount), + Reason: string(service.GrokCredentialReasonRevoked), Message: "Grok OAuth credentials require account action", + }}) + closeOpenAIWSFailoverExhausted(c, nil, &service.UpstreamFailoverError{ + Stage: service.GatewayFailureStageAccountAuth, + Scope: service.GatewayFailureScopeAccount, + Reason: service.GrokCredentialReasonRevoked, + NextAccountAction: service.NextAccountStop, + }) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil) + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, "account_auth", job.entry.ErrorPhase) + require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode) + require.Equal(t, service.GrokCredentialUnavailableClientMessage, job.entry.ErrorMessage) } diff --git a/backend/internal/handler/openai_gateway_cyber_test.go b/backend/internal/handler/openai_gateway_cyber_test.go index b0060963db..1b9fc53a9d 100644 --- a/backend/internal/handler/openai_gateway_cyber_test.go +++ b/backend/internal/handler/openai_gateway_cyber_test.go @@ -24,7 +24,7 @@ func TestRecordCyberPolicyIfMarked_NoMark(t *testing.T) { c := newTestGinContext() h := &OpenAIGatewayHandler{} - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "") // Flag must NOT be set when there was no mark. require.False(t, c.GetBool(cyberPolicyRecordedKey), @@ -47,14 +47,14 @@ func TestRecordCyberPolicyIfMarked_WithMark(t *testing.T) { // First call: should set the flag. require.NotPanics(t, func() { - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "") }) require.True(t, c.GetBool(cyberPolicyRecordedKey), "cyberPolicyRecordedKey must be true after first call with a mark") // Second call: flag already set — must be a no-op (idempotent). require.NotPanics(t, func() { - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "") }) // Flag should still be true (not toggled or cleared). require.True(t, c.GetBool(cyberPolicyRecordedKey), @@ -75,7 +75,7 @@ func TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog(t *testing.T) { h := &OpenAIGatewayHandler{} require.NotPanics(t, func() { - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, "", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, nil, service.ChannelUsageFields{}, "") }) require.True(t, c.GetBool(cyberPolicyRecordedKey)) } @@ -88,7 +88,7 @@ func TestClearCyberPolicyTurnState(t *testing.T) { h := &OpenAIGatewayHandler{} service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn1", UpstreamStatus: 200}) - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "") require.True(t, c.GetBool(cyberPolicyRecordedKey)) clearCyberPolicyTurnState(c) @@ -97,7 +97,7 @@ func TestClearCyberPolicyTurnState(t *testing.T) { // turn2: a fresh cyber hit must be recordable again. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn2", UpstreamStatus: 200}) - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "") require.True(t, c.GetBool(cyberPolicyRecordedKey)) require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message) } @@ -139,6 +139,23 @@ func TestRejectIfCyberSessionBlocked_FailOpen(t *testing.T) { require.False(t, h2.rejectIfCyberSessionBlocked(c, key, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil gateway service → pass") } +func TestBuildCyberSessionBlockWritePlanCombinesExplicitAndTranscriptKeys(t *testing.T) { + body := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`) + c := newTestGinContext() + c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(string(body))) + c.Request.RemoteAddr = "203.0.113.44:12345" + c.Request.Header.Set("User-Agent", "client/1.2.3") + + plan := buildCyberSessionBlockWritePlan(7, c, body) + require.Len(t, plan.keys, 2) + require.NotEmpty(t, plan.scopeKey) + + c.Request.Header.Set("session_id", "sess-explicit") + plan = buildCyberSessionBlockWritePlan(7, c, body) + require.Len(t, plan.keys, 3) + require.NotEmpty(t, plan.scopeKey) +} + // TestRecordCyberPolicyIfMarked_BlockKeyPlumbed verifies the 6th param is // accepted and a non-empty key with nil gateway service does not panic // (write-side guards live in the service layer). @@ -147,7 +164,7 @@ func TestRecordCyberPolicyIfMarked_BlockKeyPlumbed(t *testing.T) { service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "x", UpstreamStatus: 400}) h := &OpenAIGatewayHandler{} require.NotPanics(t, func() { - h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "deadbeef", service.ChannelUsageFields{}, "") + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, []byte(`{"input":"deadbeef"}`), service.ChannelUsageFields{}, "") }) } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 26b5dd03c5..32cea2f073 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -58,7 +58,7 @@ func newOpenAIWSUnsupportedModelSwitchError(model string) error { } func shouldReportOpenAIWSProxyAccountFailure(err error) bool { - return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) + return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) && !service.IsOpenAIWSSessionPreemptedError(err) } func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping service.ChannelMappingResult, requestedModel, upstreamModel string) string { @@ -98,10 +98,34 @@ func openAIForwardSucceededForScheduling(result *service.OpenAIForwardResult) bo return result.SucceededForScheduling() } -func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string { +func openAIAccountScheduleModel(c *gin.Context, account *service.Account, forwardModel string, requireCompact bool, result *service.OpenAIForwardResult) string { + if result != nil { + if actual := strings.TrimSpace(result.UpstreamModel); actual != "" { + return actual + } + } + if c != nil { + if value, ok := c.Get(service.OpsUpstreamModelKey); ok { + if actual, ok := value.(string); ok && strings.TrimSpace(actual) != "" { + return strings.TrimSpace(actual) + } + } + } + return service.ResolveOpenAIAccountUpstreamModelForRequest(account, forwardModel, requireCompact) +} + +func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.APIKey, requestedModel string) string { if apiKey == nil || apiKey.Group == nil { return "" } + // composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x + // 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。 + if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { + if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && + (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + return "" + } + } return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel)) } @@ -164,13 +188,11 @@ func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecord func openAICompatibleRequestPlatform(ctx context.Context, apiKey *service.APIKey) string { if platform, ok := service.ResolvedTargetPlatformFromContext(ctx); ok { - if platform == service.PlatformGrok { - return service.PlatformGrok - } - return service.PlatformOpenAI + // 保留 grok 与国产供应商原值,其他归一为 openai(与调度器精确匹配语义一致)。 + return service.NormalizeOpenAICompatiblePlatform(platform) } - if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformGrok { - return service.PlatformGrok + if apiKey != nil && apiKey.Group != nil { + return service.NormalizeOpenAICompatiblePlatform(apiKey.Group.Platform) } return service.PlatformOpenAI } @@ -182,18 +204,58 @@ func openAIResponsesRequiredCapability(imageIntent bool, platform string) servic return service.OpenAIEndpointCapabilityChatCompletions } -func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool { +// openAIResponsesRequiredCapabilityForRequest returns the endpoint capability +// required by an image or Responses request. needsResponses includes both the +// legacy /responses/compact endpoint and native remote compaction v2. +func openAIResponsesRequiredCapabilityForRequest(imageIntent bool, needsResponses bool, platform string) service.OpenAIEndpointCapability { + if needsResponses && platform == service.PlatformOpenAI { + return service.OpenAIEndpointCapabilityResponses + } + return openAIResponsesRequiredCapability(imageIntent, platform) +} + +func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKey) bool { if apiKey == nil || apiKey.Group == nil { return true } if apiKey.Group.Platform == service.PlatformGrok { return true } + // 国产供应商分组与 grok 同语义:/v1/messages 就是其主要服务形态(anthropic + // 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权—— + // 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false, + // 若不豁免,CN 分组将永远 403。 + if service.IsCNProvider(apiKey.Group.Platform) { + return true + } + // composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免; + // 解析到 openai 目标则受 composite 分组自身的可配置开关控制。 + if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { + if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && + (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + return true + } + } return apiKey.Group.AllowMessagesDispatch } func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool { - return compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI, service.PlatformGrok) + return compositeTargetPlatformAllowed(c, apiKey, model, + service.PlatformOpenAI, service.PlatformGrok, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek) +} + +// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket +// 上可服务的目标平台。CN 供应商(kimi/zhipu/deepseek)刻意排除:其账号无法通过 +// WSv2 ingress 的 transport 过滤,且 WS HTTP 桥没有面向 CN 的 Responses 转换, +// 放行只会把明确的策略拒绝变成误导性的 "no available account"。 +func isResponsesWebSocketCompositePlatform(platform string) bool { + switch platform { + case service.PlatformOpenAI, service.PlatformGrok: + return true + default: + return false + } } // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler @@ -273,6 +335,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) return } + logRequestBodyReadFailure(reqLog, c.Request, err) h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") return } @@ -288,6 +351,13 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if !ok { return } + legacyCompact := service.IsOpenAIResponsesCompactPath(c) + nativeV2 := isBareOpenAIResponsesPath(c) && isOpenAIRemoteCompactionV2Request(body) + if nativeV2 { + // 原生 v2 压缩出站前补注 x-codex-beta-features: remote_compaction_v2, + // 与真实 Codex 线型一致(网关链剥头后本级负责恢复,#5586)。 + service.MarkOpenAINativeCompactionV2(c) + } // body-signal compact:上游 unary 等待期间向下游发 SSE 注释行心跳,防止 // 反向代理空闲超时掐断长压缩连接(#3887)。首拍延迟一个心跳间隔,快速 // 失败仍走 JSON+状态码链路;未标记客户端流式或间隔为 0 时是 no-op。 @@ -309,7 +379,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } reqModel := modelResult.String() ensureCompositeTargetPlatform(c, apiKey, reqModel) - if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI, service.PlatformGrok) { + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } @@ -322,6 +392,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if _, err := service.ValidateOpenAIServiceTierField(body); err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) previousResponseID := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()) if previousResponseID != "" { @@ -338,12 +412,27 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "previous_response_id must be a response.id (resp_*), not a message id") return } - reqLog.Warn("openai.request_validation_failed", - zap.String("reason", "previous_response_id_requires_wsv2"), + groupID := int64(0) + if apiKey.GroupID != nil { + groupID = *apiKey.GroupID + } + owned, ownershipErr := h.gatewayService.ValidateOpenAIHTTPResponseOwner( + c.Request.Context(), + groupID, + previousResponseID, + subject.UserID, + apiKey.ID, ) - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "previous_response_id is only supported on Responses WebSocket v2") - return + if ownershipErr != nil { + reqLog.Warn("openai.previous_response_owner_lookup_failed", zap.Error(ownershipErr)) + } + if !owned { + reqLog.Warn("openai.request_validation_failed", zap.String("reason", "previous_response_owner_mismatch")) + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "previous_response_id is not available for this user") + return + } } + service.SetOpenAIHTTPResponseOwner(c, subject.UserID, apiKey.ID) setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) @@ -377,6 +466,15 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody) seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent) + forwardModel := reqModel + if channelMapping.Mapped { + forwardModel = channelMapping.MappedModel + } + c.Request = c.Request.WithContext(service.WithOpenAIForwardModel( + c.Request.Context(), + forwardModel, + legacyCompact, + )) // 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。 if !h.validateFunctionCallOutputRequest(c, body, reqLog) { @@ -420,7 +518,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if h.rejectIfCyberSessionBlocked(c, apiKey, sessionHashBody, reqModel, cyberBlockFormatResponses) { return } - requireCompact := isOpenAIRemoteCompactPath(c) + c.Request = c.Request.WithContext(service.WithOpenAIGuardianParentAffinity( + c.Request.Context(), c, sessionHashBody, reqModel, + )) + requireCompact := legacyCompact maxAccountSwitches := h.maxAccountSwitches switchCount := 0 @@ -437,7 +538,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 仅对 OpenAI 平台生效:Grok 生图走独立的 forwardGrokResponses 路径,不应被过滤。 // 复用前置权限与并发阶段在未修改 body 上确认的显式生图意图,避免大 tools 请求重复扫描。 // 该判断已排除 Codex 被动 image_gen namespace,避免 CC-only 账号被误过滤(#4476)。 - requiredCapability := openAIResponsesRequiredCapability(imageIntent, requestPlatform) + needsResponses := nativeV2 || legacyCompact + requiredCapability := openAIResponsesRequiredCapabilityForRequest(imageIntent, needsResponses, requestPlatform) // 分组利润控制:请求级装配定价上下文——pricingAt 固定本请求的 // D 与计费高峰因子,选号、槽位终检与全部 failover 重入共用同一门与阈值。 @@ -479,12 +581,13 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { zap.Int("excluded_account_count", len(failedAccountIDs)), ) if len(failedAccountIDs) == 0 { - if errors.Is(err, service.ErrNoAvailableCompactAccounts) { + if legacyCompact && errors.Is(err, service.ErrNoAvailableCompactAccounts) { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available accounts support /responses/compact", streamStarted) return } cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, requestPlatform) + cls = classifySelectionFailureError(err, cls) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -519,6 +622,29 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { zap.Float64("load_skew", scheduleDecision.LoadSkew), ) account := selection.Account + if previousResponseID != "" && requestPlatform == service.PlatformOpenAI && !account.IsOpenAIApiKey() { + // The public Responses HTTP API supports previous_response_id on API-key + // accounts. OAuth/SetupToken upstreams do not, so keep searching instead + // of silently deleting continuation state from a mixed account pool. + failedAccountIDs[account.ID] = struct{}{} + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + selection.ReleaseFunc = nil + } + lastFailoverErr = &service.UpstreamFailoverError{ + StatusCode: http.StatusBadRequest, + Stage: service.GatewayFailureStageInference, + Scope: service.GatewayFailureScopeRequest, + Reason: service.OpenAIHTTPContinuationUnsupportedReason, + ClientStatusCode: http.StatusBadRequest, + ClientMessage: "previous_response_id requires an OpenAI API-key account for HTTP requests", + } + reqLog.Debug("openai.account_skipped_http_continuation_unsupported", + zap.Int64("account_id", account.ID), + zap.String("account_type", account.Type), + ) + continue + } sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account) reqLog.Debug("openai.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name)) setOpsSelectedAccount(c, account.ID, account.Platform) @@ -540,7 +666,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // Forward request service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) forwardStart := time.Now() - // 用扣除 compact 心跳字节的口径快照:心跳注释不构成语义响应, + // 用扣除非语义心跳字节的口径快照:心跳注释不构成语义响应, // 不能因心跳字节变化而放弃 failover 换号(#3887)。 writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c) // 跨 passthrough 边界的 failover:从 Kiro 等透传账号切到 Bedrock 等非透传账号前, @@ -555,11 +681,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { }() return h.gatewayService.Forward(c.Request.Context(), c, account, attemptBody) }() - cyberBlockKeyHTTP := "" + var cyberBlockBodyHTTP []byte if service.GetOpsCyberPolicy(c) != nil { - cyberBlockKeyHTTP = service.CyberSessionBlockKey(apiKey.ID, c, sessionHashBody) + cyberBlockBodyHTTP = sessionHashBody } - h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyHTTP, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body)) + h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockBodyHTTP, clientRequestedUsageFields(c, channelMapping, reqModel, ""), service.HashUsageRequestPayload(body)) forwardDurationMs := time.Since(forwardStart).Milliseconds() upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey) responseLatencyMs := forwardDurationMs @@ -570,6 +696,50 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if err == nil && result != nil && result.FirstTokenMs != nil { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } + // #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常 + // 入账;failover 错误恒定 result=nil,不会重复计费。 + submitResponsesUsage := func(res *service.OpenAIForwardResult) { + if res == nil { + return + } + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) + cyberBlocked := service.GetOpsCyberPolicy(c) != nil + h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ + Result: res, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + SessionID: sessionID, + ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel), + PricingAt: pricingAt, + CyberBlocked: cyberBlocked, + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.responses"), + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("model", reqModel), + zap.Int64("account_id", account.ID), + ).Error("openai.record_usage_failed", zap.Error(err)) + } + }) + } if err != nil { if result != nil && result.ImageCount > 0 { reqLog.Warn("openai.forward_partial_error_with_image_result", @@ -588,14 +758,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { return } if !openAIForwardMayFailover(c, writerSizeBeforeForward, failoverErr) { + h.gatewayService.ObserveOpenAIAccountHealthFailure(c.Request.Context(), account, err) h.handleFailoverExhausted(c, failoverErr, true) return } - if failoverErr.SafeToFailoverAfterWrite && c.Writer.Written() { + // openAIForwardMayFailover 已确认写出的字节不含语义输出, + // 但重试耗尽时仍须按已提交的 SSE 响应返回流内错误。 + if c.Writer.Written() { streamStarted = true } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, forwardModel, requireCompact, nil), false, nil, err) } if !failoverErr.ShouldRetryNextAccount() { h.handleFailoverExhausted(c, failoverErr, streamStarted) @@ -607,8 +780,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) + if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai.pool_mode_same_account_retry", @@ -657,7 +830,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { reqLog.Warn("openai.upstream_failover_switching", failoverSwitchFields...) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, forwardModel, requireCompact, result), false, nil, err) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { @@ -669,6 +842,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated), zap.Error(err), } + submitResponsesUsage(result) if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) { reqLog.Warn("openai.forward_failed", fields...) return @@ -682,51 +856,13 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, forwardModel, requireCompact, result), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, forwardModel, requireCompact, result), openAIForwardSucceededForScheduling(result), nil) } - // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) - userAgent := c.GetHeader("User-Agent") - clientIP := ip.GetClientIP(c) - requestPayloadHash := service.HashUsageRequestPayload(body) - inboundEndpoint := GetInboundEndpoint(c) - upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) - quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) - sessionID := service.ExtractClientSessionID(c) - // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 - cyberBlocked := service.GetOpsCyberPolicy(c) != nil - h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { - if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ - Result: result, - APIKey: apiKey, - User: apiKey.User, - Account: account, - Subscription: subscription, - InboundEndpoint: inboundEndpoint, - UpstreamEndpoint: upstreamEndpoint, - UserAgent: userAgent, - IPAddress: clientIP, - RequestPayloadHash: requestPayloadHash, - APIKeyService: h.apiKeyService, - QuotaPlatform: quotaPlatform, - SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), - PricingAt: pricingAt, - CyberBlocked: cyberBlocked, - }); err != nil { - logger.L().With( - zap.String("component", "handler.openai_gateway.responses"), - zap.Int64("user_id", subject.UserID), - zap.Int64("api_key_id", apiKey.ID), - zap.Any("group_id", apiKey.GroupID), - zap.String("model", reqModel), - zap.Int64("account_id", account.ID), - ).Error("openai.record_usage_failed", zap.Error(err)) - } - }) + submitResponsesUsage(result) reqLog.Debug("openai.request_completed", zap.Int64("account_id", account.ID), zap.Int("switch_count", switchCount), @@ -735,12 +871,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } } -func isOpenAIRemoteCompactPath(c *gin.Context) bool { - if c == nil || c.Request == nil || c.Request.URL == nil { - return false - } - normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") - return strings.HasSuffix(normalizedPath, "/responses/compact") +func isOpenAILegacyCompactPath(c *gin.Context) bool { + return service.IsOpenAIResponsesCompactPath(c) } // isBareOpenAIResponsesPath 仅匹配裸 /responses 端点(无 /compact 等子路径), @@ -750,32 +882,32 @@ func isBareOpenAIResponsesPath(c *gin.Context) bool { return false } normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") - return strings.HasSuffix(normalizedPath, "/responses") -} - -func isOpenAIRemoteCompactionV2Request(c *gin.Context, body []byte) bool { - stream, valid := parseOpenAICompatibleStream(body) - if !valid || !stream || c == nil || c.Request == nil { + switch normalizedPath { + case EndpointResponses, "/openai/v1/responses", "/responses", "/backend-api/codex/responses": + return true + default: return false } - for _, header := range c.Request.Header.Values("x-codex-beta-features") { - for _, feature := range strings.Split(header, ",") { - if strings.TrimSpace(feature) == "remote_compaction_v2" { - return true - } - } - } - return false +} + +func isOpenAIRemoteCompactionV2Request(body []byte) bool { + stream, valid := parseOpenAICompatibleStream(body) + return valid && stream && service.HasCompactionTriggerInInput(body) } // normalizeOpenAIResponsesCompactRequest keeps Codex remote compaction v2 on // its native streaming /responses wire and preserves the legacy body-signal -// promotion for clients that do not explicitly advertise that protocol. +// promotion for non-streaming requests. // 返回归一化后的 body;ok=false 表示错误响应已写出,调用方应直接 return。 func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Context, reqLog *zap.Logger, body []byte) ([]byte, bool) { - isCompactRequest := service.IsOpenAIResponsesCompactPathForTest(c) + isCompactRequest := isOpenAILegacyCompactPath(c) if !isCompactRequest && isBareOpenAIResponsesPath(c) && service.HasCompactionTriggerInInput(body) { - if isOpenAIRemoteCompactionV2Request(c, body) { + if normalized, changed, err := service.NormalizeCompactionTriggerInputOrder(body); err != nil { + reqLog.Warn("codex.remote_compact.trigger_order_normalization_failed", zap.Error(err)) + } else if changed { + body = normalized + } + if isOpenAIRemoteCompactionV2Request(body) { return body, true } c.Request.URL.Path = strings.TrimRight(c.Request.URL.Path, "/") + "/compact" @@ -804,7 +936,7 @@ func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Con } func (h *OpenAIGatewayHandler) logOpenAIRemoteCompactOutcome(c *gin.Context, startedAt time.Time) { - if !isOpenAIRemoteCompactPath(c) { + if !isOpenAILegacyCompactPath(c) { return } @@ -910,7 +1042,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { ) // 检查分组是否允许 /v1/messages 调度 - if !allowOpenAICompatibleMessagesDispatch(apiKey) { + if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return @@ -953,7 +1085,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) @@ -1112,11 +1244,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { }() return h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel) }() - cyberBlockKeyMsg := "" + var cyberBlockBodyMsg []byte if service.GetOpsCyberPolicy(c) != nil { - cyberBlockKeyMsg = service.CyberSessionBlockKey(apiKey.ID, c, body) + cyberBlockBodyMsg = body } - h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyMsg, clientRequestedUsageFields(c, channelMappingMsg, reqModel, ""), service.HashUsageRequestPayload(body)) + h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockBodyMsg, clientRequestedUsageFields(c, channelMappingMsg, reqModel, ""), service.HashUsageRequestPayload(body)) forwardDurationMs := time.Since(forwardStart).Milliseconds() upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey) responseLatencyMs := forwardDurationMs @@ -1127,6 +1259,51 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { if err == nil && result != nil && result.FirstTokenMs != nil { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } + // Forward 与错误一起返回的部分结果:流中断/客户端断开排水前上游已计量的 + // usage 照常入账,避免上游已产生消耗的请求完全漏记(#5148,对齐 anthropic + // 网关同名修复)。failover 错误恒定 result=nil,不会重复计费。 + submitMessagesUsage := func(res *service.OpenAIForwardResult) { + if res == nil { + return + } + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) + cyberBlocked := service.GetOpsCyberPolicy(c) != nil + h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ + Result: res, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + SessionID: sessionID, + ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, res.UpstreamModel), + PricingAt: pricingAt, + CyberBlocked: cyberBlocked, + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.messages"), + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("model", reqModel), + zap.Int64("account_id", account.ID), + ).Error("openai_messages.record_usage_failed", zap.Error(err)) + } + }) + } if err != nil { if result != nil && result.ImageCount > 0 { reqLog.Warn("openai_messages.forward_partial_error_with_image_result", @@ -1145,11 +1322,12 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { return } if c.Writer.Size() != writerSizeBeforeForward { + h.gatewayService.ObserveOpenAIAccountHealthFailure(c.Request.Context(), account, err) h.handleAnthropicFailoverExhausted(c, failoverErr, true) return } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, currentRoutingModel, false, nil), false, nil, err) } if !failoverErr.ShouldRetryNextAccount() { h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) @@ -1157,8 +1335,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } // 池模式:同账号重试 if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) + if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { sameAccountRetryCount[account.ID]++ retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_messages.pool_mode_same_account_retry", @@ -1201,62 +1379,29 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Error(err), ) + // 断开排水期间上游已计量的 usage 必须入账(此前直接 return 丢弃, + // payg 上游照常计费而平台漏记)。 + submitMessagesUsage(result) return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, currentRoutingModel, false, result), false, nil, err) wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted) reqLog.Warn("openai_messages.forward_failed", zap.Int64("account_id", account.ID), zap.Bool("fallback_error_response_written", wroteFallback), zap.Error(err), ) + submitMessagesUsage(result) return } } if result != nil { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, currentRoutingModel, false, result), true, result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, currentRoutingModel, false, result), true, nil) } - userAgent := c.GetHeader("User-Agent") - clientIP := ip.GetClientIP(c) - requestPayloadHash := service.HashUsageRequestPayload(body) - inboundEndpoint := GetInboundEndpoint(c) - upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) - quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) - sessionID := service.ExtractClientSessionID(c) - - cyberBlocked := service.GetOpsCyberPolicy(c) != nil - h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { - if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ - Result: result, - APIKey: apiKey, - User: apiKey.User, - Account: account, - Subscription: subscription, - InboundEndpoint: inboundEndpoint, - UpstreamEndpoint: upstreamEndpoint, - UserAgent: userAgent, - IPAddress: clientIP, - RequestPayloadHash: requestPayloadHash, - APIKeyService: h.apiKeyService, - QuotaPlatform: quotaPlatform, - SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel), - PricingAt: pricingAt, - CyberBlocked: cyberBlocked, - }); err != nil { - logger.L().With( - zap.String("component", "handler.openai_gateway.messages"), - zap.Int64("user_id", subject.UserID), - zap.Int64("api_key_id", apiKey.ID), - zap.Any("group_id", apiKey.GroupID), - zap.String("model", reqModel), - zap.Int64("account_id", account.ID), - ).Error("openai_messages.record_usage_failed", zap.Error(err)) - } - }) + submitMessagesUsage(result) reqLog.Debug("openai_messages.request_completed", zap.Int64("account_id", account.ID), zap.Int("switch_count", switchCount), @@ -1321,6 +1466,14 @@ func (h *OpenAIGatewayHandler) handleAnthropicFailoverExhausted(c *gin.Context, h.anthropicStreamingAwareError(c, status, "api_error", message, streamStarted) return } + if failoverErr != nil && failoverErr.IsOpenAICapacityShed() && strings.TrimSpace(failoverErr.ClientMessage) != "" { + status := failoverErr.ClientStatusCode + if status <= 0 { + status = http.StatusServiceUnavailable + } + h.anthropicStreamingAwareError(c, status, "api_error", failoverErr.ClientMessage, streamStarted) + return + } status, errType, errMsg := h.mapUpstreamError(failoverErr.StatusCode) h.anthropicStreamingAwareError(c, status, errType, errMsg, streamStarted) } @@ -1402,12 +1555,10 @@ const ( // 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在 // 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。 // -// 零值语义(重要):ws_v2 passthrough ingress 只实现了 AfterTurn,没有任何 -// turn 起始回调,BeforeTurn 永远不会被调用。此时本值保持零,RecordUsage 经 -// openAIUsagePricingAt 回退到记录时刻——与引入分组利润控制前的基线一致。 -// 绝不能用建连时刻初始化:那会把透传连接的所有 turn 钉死在建连时的高峰因子, -// 客户端只要峰前一分钟建连并保活,整条连接就能全程按谷价结算,正是利润控制 -// 想堵的漏洞。透传 ingress 目前不做 turn 级利润复核,只有建连时的准入门。 +// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须 +// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。 +// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行 +// 准入门、没有 turn 级利润复核的既有行为。 type openAIWSTurnPricing struct { mu sync.Mutex at time.Time @@ -1419,10 +1570,13 @@ func (p *openAIWSTurnPricing) freeze(at time.Time) { p.mu.Unlock() } -func (p *openAIWSTurnPricing) current() time.Time { +func (p *openAIWSTurnPricing) currentOr(fallback time.Time) time.Time { p.mu.Lock() defer p.mu.Unlock() - return p.at + if !p.at.IsZero() { + return p.at + } + return fallback } // recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号 @@ -1462,9 +1616,32 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( streamStarted *bool, reqLog *zap.Logger, ) (func(), openAISlotAcquireResult) { + return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil) +} + +type openAISlotErrorWriter func(status int, errType, message string) + +// acquireOpenAIAccountSlot centralizes scheduler selection admission. The +// optional error writer lets non-Responses endpoints retain their wire format +// while sharing the same WaitPlan, cancellation, and release semantics. +func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( + c *gin.Context, + groupID *int64, + sessionHash string, + selection *service.AccountSelectionResult, + reqStream bool, + streamStarted *bool, + reqLog *zap.Logger, + writeError openAISlotErrorWriter, +) (func(), openAISlotAcquireResult) { + if writeError == nil { + writeError = func(status int, errType, message string) { + h.handleStreamingAwareError(c, status, errType, message, *streamStarted) + } + } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) + writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") return nil, openAISlotAcquireFailed } @@ -1494,7 +1671,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) + writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") return nil, openAISlotAcquireFailed } @@ -1505,7 +1682,8 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - h.handleConcurrencyError(c, err, "account", *streamStarted) + status, errType, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, message) return nil, openAISlotAcquireFailed } if fastAcquired { @@ -1535,7 +1713,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted) + writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later") return nil, openAISlotAcquireFailed } @@ -1558,7 +1736,8 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - h.handleConcurrencyError(c, err, "account", *streamStarted) + status, errType, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, message) return nil, openAISlotAcquireFailed } @@ -1685,6 +1864,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "missing first response.create message") return } + firstTurnStartedAt := time.Now() if msgType != coderws.MessageText && msgType != coderws.MessageBinary { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "unsupported websocket message type") return @@ -1702,7 +1882,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { platform, ok := service.ResolvedTargetPlatformFromContext(ctx) - if !ok || (platform != service.PlatformOpenAI && platform != service.PlatformGrok) { + if !ok || !isResponsesWebSocketCompositePlatform(platform) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "Responses WebSocket API only supports OpenAI-compatible models for composite groups") return } @@ -1736,19 +1916,36 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } - // F5a: 握手层会话屏蔽检查。WS 握手无 body,显式标识仅来自握手 header - // (session_id / conversation_id);无标识则放行,连接内仍有本地 flag 兜底。 - cyberBlockKey := service.CyberSessionBlockKey(apiKey.ID, c, nil) - if cyberBlockKey != "" && h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), cyberBlockKey) { + // The first response.create frame is available here, so explicit IDs are + // checked directly and body-derived sessions use the coarse scope gate. + if cyberBlockKey := findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, firstMessage); cyberBlockKey != "" { writeCyberSessionBlockedWSError(c.Request.Context(), wsConn) closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "session blocked by cyber-security policy") h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, reqModel, cyberBlockKey) return } cyberBlockedThisConn := false + var cyberTurnBodiesMu sync.Mutex + cyberTurnBodies := map[int][]byte{1: append([]byte(nil), firstMessage...)} + setCyberTurnBody := func(turn int, payload []byte) { + cyberTurnBodiesMu.Lock() + cyberTurnBodies[turn] = append([]byte(nil), payload...) + cyberTurnBodiesMu.Unlock() + } + takeCyberTurnBody := func(turn int) []byte { + cyberTurnBodiesMu.Lock() + body := cyberTurnBodies[turn] + delete(cyberTurnBodies, turn) + cyberTurnBodiesMu.Unlock() + return body + } // 解析渠道级模型映射 channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) + wsForwardModel := reqModel + if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" { + wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel) + } var currentUserRelease func() var currentAccountRelease func() @@ -1814,22 +2011,48 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { firstMessage, openAIWSIngressFallbackSessionSeed(subject.UserID, apiKey.ID, apiKey.GroupID), ) + ctx = service.WithOpenAIGuardianParentAffinity(ctx, c, firstMessage, reqModel) maxAccountSwitches := h.maxAccountSwitches switchCount := 0 profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) + sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState + wsAttemptMessage := append([]byte(nil), firstMessage...) + waitForWSSameAccountRetry := func(account *service.Account, failoverErr *service.UpstreamFailoverError) bool { + if account == nil || failoverErr == nil || failoverErr.StatusCode != http.StatusTooManyRequests || failoverErr.SameAccountRetryDeadline.IsZero() { + return false + } + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) + if !sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { + return false + } + sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) + reqLog.Warn("openai.websocket.same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + zap.Duration("retry_delay", retryDelay), + ) + select { + case <-ctx.Done(): + return false + case <-time.After(retryDelay): + return true + } + } handleWSFailover := func(account *service.Account, failoverErr *service.UpstreamFailoverError) bool { if ctx.Err() != nil { return false } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, wsForwardModel, false, nil), false, nil, failoverErr) } releaseAccountSlot() if !failoverErr.ShouldRetryNextAccount() { - closeOpenAIWSFailoverExhausted(wsConn, failoverErr) + closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr) return false } if ctx.Err() != nil { @@ -1839,12 +2062,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr if switchCount >= maxAccountSwitches { - closeOpenAIWSFailoverExhausted(wsConn, failoverErr) + closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr) return false } switchCount++ if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) { - closeOpenAIWSFailoverExhausted(wsConn, failoverErr) + closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr) return false } reqLog.Warn("openai.websocket_upstream_failover_switching", @@ -1900,7 +2123,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { zap.Int("excluded_account_count", len(failedAccountIDs)), ) if lastFailoverErr != nil { - closeOpenAIWSFailoverExhausted(wsConn, lastFailoverErr) + closeOpenAIWSFailoverExhausted(c, wsConn, lastFailoverErr) } else { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") } @@ -1908,7 +2131,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } if selection == nil || selection.Account == nil { if lastFailoverErr != nil { - closeOpenAIWSFailoverExhausted(wsConn, lastFailoverErr) + closeOpenAIWSFailoverExhausted(c, wsConn, lastFailoverErr) } else { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") } @@ -1982,6 +2205,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } // 准入完成:门并入连接 ctx,turn 级复核与 failover 重选共用。 ctx = admissionCtx + // Account selection starts a fresh upstream attempt. Clear any model + // captured by the previous failover account before credential lookup. + setOpsSelectedAccount(c, account.ID, account.Platform) currentAccountRelease = wrapReleaseOnDone(ctx, accountReleaseFunc) if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, apiKey.GroupID, sessionHash, account.ID); err != nil { reqLog.Warn("openai.websocket_bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) @@ -2013,21 +2239,41 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey) var requestPayloadHash string + var turnStartsMu sync.Mutex + turnStarts := make(map[int]time.Time, 4) + recordTurnStart := func(turn int, startedAt time.Time) { + if turn <= 0 || startedAt.IsZero() { + return + } + turnStartsMu.Lock() + turnStarts[turn] = startedAt + turnStartsMu.Unlock() + } + getTurnStart := func(turn int) time.Time { + turnStartsMu.Lock() + startedAt := turnStarts[turn] + delete(turnStarts, turn) + turnStartsMu.Unlock() + return startedAt + } // Passthrough rejects overlapping response.create frames, so one immutable // turn-tagged slot preserves the exact mapping used for the in-flight request. var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) - // turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号, - // AfterTurn 的计费读取所属 turn 的时刻。零值起步的语义见 - // openAIWSTurnPricing 的注释——绝不能用建连时刻初始化。 + // turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号; + // passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。 var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ ClientLifecycleContext: clientLifecycleCtx, InitialRequestModel: reqModel, + InitialTurnStartedAt: firstTurnStartedAt, MaxReasoningEffort: maxReasoningEffort, ReasoningEffortMappings: reasoningEffortMappings, + TurnStarted: recordTurnStart, BeforeRequest: func(turn int, payload []byte, originalModel string) error { c.Set(securityAuditWSTurnContextKey, turn) + service.BeginOpsStreamTurn(c, turn) + setCyberTurnBody(turn, payload) if turn == 1 { return nil } @@ -2052,6 +2298,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if model == "" { model = reqModel } + setOpsRequestContext(c, model, true) mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) mappedModelUnchanged := false if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { @@ -2111,6 +2358,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return nil }, AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) { + turnStart := getTurnStart(turn) + cyberBlockBody := takeCyberTurnBody(turn) // F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行; // CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行, // 届时 defer 已清除标记)。 @@ -2136,7 +2385,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { turnUpstreamModel = turnRequestedModel } turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel) - h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockKey, turnUsageFields, requestPayloadHash) + h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockBody, turnUsageFields, requestPayloadHash) if service.GetOpsCyberPolicy(c) != nil { cyberBlockedThisConn = true } @@ -2173,12 +2422,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if scheduleModel == "" { scheduleModel = turnRequestedModel } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) - turnRecordPricingAt := turnPricing.current() + turnRecordPricingAt := turnPricing.currentOr(turnStart) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) { if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{ @@ -2209,7 +2458,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, } - wsFirstMessage := firstMessage + wsFirstMessage := wsAttemptMessage // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 @@ -2224,13 +2473,62 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。 requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage) + if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, wsFirstMessage); armed { + ctx = preemptCtx + defer cleanupPreempt() + } - if err := h.gatewayService.ProxyResponsesWebSocketFromClient(ctx, c, wsConn, account, token, wsFirstMessage, hooks); err != nil { + for { + err := h.gatewayService.ProxyResponsesWebSocketFromClient(ctx, c, wsConn, account, token, wsFirstMessage, hooks) + if err == nil { + reqLog.Info("openai.websocket_ingress_closed", zap.Int64("account_id", account.ID)) + return + } + if service.IsOpenAIWSSessionPreemptedError(err) { + return + } var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - if handleWSFailover(account, failoverErr) { + retryPayload, retryCurrentTurn := service.OpenAIWSCurrentTurnRetryPayload(err) + nextAttemptMessage, retrySafe := openAIWSNextAttemptMessage(wsAttemptMessage, retryPayload, retryCurrentTurn) + if !retrySafe { + closeOpenAIWSFailoverExhausted(c, wsConn, failoverErr) + return + } + wsAttemptMessage = nextAttemptMessage + if retryCurrentTurn { + previousResponseID = "" + reqLog.Warn("openai.websocket_current_turn_failover_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_payload_bytes", len(retryPayload)), + ) + } + if waitForWSSameAccountRetry(account, failoverErr) { + if failoverErr.ShouldReportAccountScheduleFailure() { + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, wsForwardModel, false, nil), false, nil, err) + } + if !ensureUserSlotHeld() { + return + } + if currentAccountRelease == nil { + accountRelease, acquired, acquireErr := h.concurrencyHelper.TryAcquireAccountSlot(ctx, account.ID, accountMaxConcurrency) + if acquireErr != nil || !acquired { + reqLog.Warn("openai.websocket_same_account_retry_slot_unavailable", + zap.Int64("account_id", account.ID), + zap.Error(acquireErr), + ) + closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later") + return + } + currentAccountRelease = wrapReleaseOnDone(ctx, accountRelease) + } + wsFirstMessage = wsAttemptMessage continue } + if handleWSFailover(account, failoverErr) { + break + } return } @@ -2254,7 +2552,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } if shouldReportOpenAIWSProxyAccountFailure(err) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, wsForwardModel, false, nil), false, nil, err) } closeStatus, closeReason := summarizeWSCloseErrorForLog(err) proxyFailedFields := []zap.Field{ @@ -2281,8 +2579,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "upstream websocket proxy failed") return } - reqLog.Info("openai.websocket_ingress_closed", zap.Int64("account_id", account.ID)) - return } } @@ -2498,12 +2794,28 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE ) return } + if failoverErr.Reason == service.OpenAIHTTPContinuationUnsupportedReason { + message := strings.TrimSpace(failoverErr.ClientMessage) + if message == "" { + message = "previous_response_id requires an OpenAI API-key account for HTTP requests" + } + h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", message, streamStarted) + return + } copyFailoverRetryAfter(c, failoverErr.ResponseHeaders) if failoverErr.IsCredentialFailure() { status, message := credentialFailoverClientResponse(failoverErr) h.handleStreamingAwareError(c, status, "upstream_error", message, streamStarted) return } + if failoverErr.IsOpenAICapacityShed() && strings.TrimSpace(failoverErr.ClientMessage) != "" { + status := failoverErr.ClientStatusCode + if status <= 0 { + status = http.StatusServiceUnavailable + } + h.handleStreamingAwareError(c, status, "server_error", failoverErr.ClientMessage, streamStarted) + return + } statusCode := failoverErr.StatusCode responseBody := failoverErr.ResponseBody if service.IsOpenAISilentRefusalErrorBody(responseBody) { @@ -2546,6 +2858,13 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE } func credentialFailoverClientResponse(failoverErr *service.UpstreamFailoverError) (int, string) { + if failoverErr != nil && failoverErr.Reason == service.OpenAIUpstreamAccessStateReason && strings.TrimSpace(failoverErr.ClientMessage) != "" { + status := failoverErr.ClientStatusCode + if status <= 0 { + status = http.StatusServiceUnavailable + } + return status, failoverErr.ClientMessage + } if failoverErr != nil && failoverErr.Reason == service.AntigravityCredentialRejectedReason { return http.StatusBadGateway, service.AntigravityCredentialRejectedClientMessage } @@ -2870,25 +3189,54 @@ func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason s _ = conn.CloseNow() } -func closeOpenAIWSFailoverExhausted(conn *coderws.Conn, failoverErr *service.UpstreamFailoverError) { - if failoverErr == nil { - closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed") - return +func openAIWSNextAttemptMessage(current, retryPayload []byte, retryCurrentTurn bool) ([]byte, bool) { + if !retryCurrentTurn { + return append([]byte(nil), current...), true } - if failoverErr.Stage == service.GatewayFailureStageAccountAuth { - closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, service.GrokCredentialUnavailableClientMessage) - return + if len(retryPayload) == 0 { + return nil, false } - switch failoverErr.StatusCode { - case http.StatusTooManyRequests: - closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, "upstream rate limit exceeded, please retry later") - case 529, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: - closeOpenAIClientWS(conn, coderws.StatusTryAgainLater, "upstream service temporarily unavailable") - case http.StatusUnauthorized, http.StatusForbidden: - closeOpenAIClientWS(conn, coderws.StatusPolicyViolation, "upstream websocket authentication failed") - default: - closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed") + return append([]byte(nil), retryPayload...), true +} + +func closeOpenAIWSFailoverExhausted(c *gin.Context, conn *coderws.Conn, failoverErr *service.UpstreamFailoverError) { + intendedStatus := http.StatusBadGateway + errorType := "upstream_error" + errorCode := "upstream_ws_failover_exhausted" + message := "upstream websocket proxy failed" + closeStatus := coderws.StatusInternalError + + if failoverErr != nil { + if reason := strings.TrimSpace(string(failoverErr.Reason)); reason != "" { + errorCode = reason + } + if failoverErr.Stage == service.GatewayFailureStageAccountAuth { + intendedStatus = http.StatusServiceUnavailable + errorType = "api_error" + message = service.GrokCredentialUnavailableClientMessage + closeStatus = coderws.StatusTryAgainLater + } else { + switch failoverErr.StatusCode { + case http.StatusTooManyRequests: + intendedStatus = http.StatusTooManyRequests + errorType = "rate_limit_error" + message = "upstream rate limit exceeded, please retry later" + closeStatus = coderws.StatusTryAgainLater + case 529, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + intendedStatus = failoverErr.StatusCode + message = "upstream service temporarily unavailable" + closeStatus = coderws.StatusTryAgainLater + case http.StatusUnauthorized, http.StatusForbidden: + intendedStatus = failoverErr.StatusCode + errorType = "authentication_error" + message = "upstream websocket authentication failed" + closeStatus = coderws.StatusPolicyViolation + } + } } + + service.MarkOpsStreamFailure(c, errorType, errorCode, message, intendedStatus) + closeOpenAIClientWS(conn, closeStatus, message) } func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, decision *service.ContentModerationDecision) { @@ -3083,13 +3431,10 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe if enabled, _ := h.gatewayService.CyberSessionBlockRuntime(c.Request.Context()); !enabled { return false } - key := service.CyberSessionBlockKey(apiKey.ID, c, body) + key := findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, body) if key == "" { return false } - if !h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), key) { - return false - } // body-signal compact 心跳可能已把响应头提交为 200(cyber 检查在用户槽位 // 长等待之后执行):以 response.failed 终止事件回传;未提交时停拍后照常 // 写 JSON(#3887)。 @@ -3117,12 +3462,56 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe return true } +type cyberSessionBlockWritePlan struct { + scopeKey string + keys []string +} + +func buildCyberSessionBlockWritePlan(apiKeyID int64, c *gin.Context, body []byte) cyberSessionBlockWritePlan { + plan := cyberSessionBlockWritePlan{} + if key := service.CyberSessionExplicitBlockKey(apiKeyID, c, body); key != "" { + plan.keys = append(plan.keys, key) + } + transcriptKeys := service.CyberSessionTranscriptBlockKeys(apiKeyID, body) + for _, key := range transcriptKeys { + if len(plan.keys) == 0 || key != plan.keys[0] { + plan.keys = append(plan.keys, key) + } + } + if len(transcriptKeys) > 0 { + plan.scopeKey = cyberSessionScopeKey(apiKeyID, c) + } + return plan +} + +func findBlockedCyberSessionKey(ctx context.Context, gatewayService *service.OpenAIGatewayService, apiKeyID int64, c *gin.Context, body []byte) string { + if gatewayService == nil { + return "" + } + clientIP, userAgent := "", "" + if c != nil { + clientIP = strings.TrimSpace(ip.GetClientIP(c)) + userAgent = c.GetHeader("User-Agent") + } + return gatewayService.FindCyberSessionBlockedForRequest(ctx, apiKeyID, c, body, clientIP, userAgent) +} + +func cyberSessionScopeKey(apiKeyID int64, c *gin.Context) string { + if c == nil { + return "" + } + return service.CyberSessionScopeKey(apiKeyID, strings.TrimSpace(ip.GetClientIP(c)), c.GetHeader("User-Agent")) +} + // enqueueCyberSessionBlockedOpsEntry captures request meta and enqueues the // ops_error_logs entry for a locally blocked request. func (h *OpenAIGatewayHandler) enqueueCyberSessionBlockedOpsEntry(c *gin.Context, apiKey *service.APIKey, model string, sessionBlockKey string) { if h.opsService == nil { return } + // The dedicated cyber_session_blocked entry owns Ops semantics for this + // request; suppress the generic middleware record of the same 403 response. + c.Set(opsDedicatedErrorRecordedKey, true) meta := cyberPolicyOpsErrorMeta{Model: model, InboundEndpoint: GetInboundEndpoint(c), CreatedAt: time.Now(), SessionBlockKey: sessionBlockKey} meta.RequestID = c.Writer.Header().Get("X-Request-Id") if c.Request != nil && c.Request.URL != nil { @@ -3156,7 +3545,7 @@ func (h *OpenAIGatewayHandler) enqueueCyberSessionBlockedOpsEntry(c *gin.Context // 并在 forward 返回错误时写一条 tokens=0 用量行。标记由 gateway 服务层在透传 cyber 后设置; // 当前请求已发给用户,本方法只做事后记录,不影响响应。forwardErrored 为 true 时才写用量行, // 避免与正常 RecordUsage(forward 成功路径)重复。每请求至多记录一次。 -func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, model string, forwardErrored bool, cyberBlockKey string, channelFields service.ChannelUsageFields, requestPayloadHash string) { +func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, model string, forwardErrored bool, cyberBlockBody []byte, channelFields service.ChannelUsageFields, requestPayloadHash string) { mark := service.GetOpsCyberPolicy(c) if mark == nil { return @@ -3238,6 +3627,14 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey ClientIP: clientIPStr, CreatedAt: time.Now(), } + if gwSvc != nil && apiKey != nil { + plan := buildCyberSessionBlockWritePlan(apiKey.ID, c, cyberBlockBody) + if len(plan.keys) > 0 { + blockCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + gwSvc.MarkCyberSessionBlocked(blockCtx, plan.scopeKey, plan.keys) + cancel() + } + } go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -3279,9 +3676,6 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey ChannelUsageFields: channelFields, }) } - if gwSvc != nil && cyberBlockKey != "" { - gwSvc.MarkCyberSessionBlocked(ctx, cyberBlockKey) - } if opsSvc != nil { enqueueOpsErrorLog(opsSvc, buildCyberPolicyOpsErrorEntry(opsMeta, mark)) } diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 6e8a687d77..86c56d4901 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -646,21 +646,21 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { }, }, } - require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) - require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-fable-5")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-fable-5")) }) t.Run("uses_family_default_when_no_override", func(t *testing.T) { apiKey := &service.APIKey{Group: &service.Group{}} - require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-opus-4-6")) - require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) - require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001")) + require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-opus-4-6")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-haiku-4-5-20251001")) }) t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) { - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, nil, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{}, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{Group: &service.Group{}}, "gpt-5.4")) }) t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) { @@ -672,8 +672,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { Platform: service.PlatformGrok, }, } - require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok")) + require.Equal(t, "grok-4.6", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "grok")) }) t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) { @@ -682,8 +682,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { DefaultMappedModel: "gpt-5.4", }, } - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "gpt-5.4")) - require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "gpt-5.4")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) }) } @@ -855,7 +855,7 @@ func TestOpenAIResponses_RejectsMessageIDAsPreviousResponseID(t *testing.T) { require.Contains(t, w.Body.String(), "previous_response_id must be a response.id") } -func TestOpenAIResponses_RejectsHTTPContinuationPreviousResponseID(t *testing.T) { +func TestOpenAIResponses_AcceptsHTTPContinuationPreviousResponseIDBeforeRouting(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -877,11 +877,59 @@ func TestOpenAIResponses_RejectsHTTPContinuationPreviousResponseID(t *testing.T) }) h := newOpenAIHandlerForPreviousResponseIDValidation(t, nil) + require.NoError(t, h.gatewayService.BindOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_123456", 1, 101)) + h.Responses(c) + + require.NotEqual(t, http.StatusBadRequest, w.Code) + require.NotContains(t, w.Body.String(), "Responses WebSocket v2") +} + +func TestOpenAIResponses_RejectsHTTPContinuationOwnedByAnotherUser(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader( + `{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_other_tenant","input":"hello"}`, + )) + c.Request.Header.Set("Content-Type", "application/json") + + groupID := int64(2) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + ID: 202, + UserID: 2, + GroupID: &groupID, + User: &service.User{ID: 2}, + }) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 2, Concurrency: 1}) + + h := newOpenAIHandlerForPreviousResponseIDValidation(t, nil) + require.NoError(t, h.gatewayService.BindOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_other_tenant", 1, 101)) h.Responses(c) require.Equal(t, http.StatusBadRequest, w.Code) - require.Contains(t, w.Body.String(), "Responses WebSocket v2") - require.Contains(t, w.Body.String(), "previous_response_id") + require.Contains(t, w.Body.String(), "previous_response_id is not available for this user") +} + +func TestOpenAIResponses_RejectsUnownedHTTPContinuation(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader( + `{"model":"gpt-5.1","stream":false,"previous_response_id":"resp_unknown","input":"hello"}`, + )) + c.Request.Header.Set("Content-Type", "application/json") + + groupID := int64(2) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ID: 101, UserID: 1, GroupID: &groupID, User: &service.User{ID: 1}}) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1}) + + h := newOpenAIHandlerForPreviousResponseIDValidation(t, nil) + h.Responses(c) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "previous_response_id is not available for this user") } func TestOpenAIResponses_FunctionCallOutputHTTPGuidanceDoesNotSuggestPreviousResponseReuse(t *testing.T) { @@ -1412,8 +1460,8 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { }) require.Len(t, got.upstreamPayloads, 2) - require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) - require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Equal(t, "sol-channel", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "terra-channel", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) require.Len(t, got.clientEvents, 2) require.Equal(t, "sol", gjson.GetBytes(got.clientEvents[0], "response.model").String()) require.Equal(t, "terra", gjson.GetBytes(got.clientEvents[1], "response.model").String()) @@ -1422,16 +1470,16 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { require.Equal(t, "sol", got.logs[0].Model) require.Equal(t, "sol", got.logs[0].RequestedModel) require.NotNil(t, got.logs[0].UpstreamModel) - require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel) + require.Equal(t, "sol-channel", *got.logs[0].UpstreamModel) require.NotNil(t, got.logs[0].ModelMappingChain) - require.Equal(t, "sol→sol-channel→gpt-5.6-sol", *got.logs[0].ModelMappingChain) + require.Equal(t, "sol→sol-channel", *got.logs[0].ModelMappingChain) require.Equal(t, "terra", got.logs[1].Model) require.Equal(t, "terra", got.logs[1].RequestedModel) require.NotNil(t, got.logs[1].UpstreamModel) - require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel) + require.Equal(t, "terra-channel", *got.logs[1].UpstreamModel) require.NotNil(t, got.logs[1].ModelMappingChain) - require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain) + require.Equal(t, "terra→terra-channel", *got.logs[1].ModelMappingChain) require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12, "each turn must be billed with its own channel-mapped model") } @@ -1593,6 +1641,29 @@ func TestOpenAIWSTurnBillingModelPreservesImagePricingModel(t *testing.T) { } } +func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) { + account := &service.Account{ + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{"public": "billing"}, + "compact_model_mapping": map[string]any{"public": "compact-actual"}, + }, + } + + reported := &service.OpenAIForwardResult{UpstreamModel: "observed-actual"} + require.Equal(t, "observed-actual", openAIAccountScheduleModel(nil, account, "public", true, reported)) + require.Equal(t, "compact-actual", openAIAccountScheduleModel(nil, account, "public", true, nil)) + require.Equal(t, "billing", openAIAccountScheduleModel(nil, account, "public", false, nil)) + + c, _ := gin.CreateTestContext(nil) + service.SetOpsUpstreamModel(c, "attempt-actual") + require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil)) + + setOpsSelectedAccount(c, account.ID, account.Platform) + require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil)) +} + func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) { t.Run("unsupported client model switch does not penalize account", func(t *testing.T) { err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported")) diff --git a/backend/internal/handler/openai_gateway_ws_failover_resume_test.go b/backend/internal/handler/openai_gateway_ws_failover_resume_test.go new file mode 100644 index 0000000000..d62f83ee62 --- /dev/null +++ b/backend/internal/handler/openai_gateway_ws_failover_resume_test.go @@ -0,0 +1,35 @@ +package handler + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenAIWSNextAttemptMessageUsesCurrentTurnPayload(t *testing.T) { + firstMessage := []byte(`{"type":"response.create","input":"first"}`) + currentTurn := []byte(`{"type":"response.create","input":"turn-281"}`) + + next, ok := openAIWSNextAttemptMessage(firstMessage, currentTurn, true) + + require.True(t, ok) + require.Equal(t, currentTurn, next) + next[0] = 'x' + require.Equal(t, byte('{'), currentTurn[0], "retry payload must be cloned") +} + +func TestOpenAIWSNextAttemptMessageRejectsMissingCurrentTurnPayload(t *testing.T) { + next, ok := openAIWSNextAttemptMessage([]byte(`{"type":"response.create"}`), nil, true) + + require.False(t, ok) + require.Nil(t, next) +} + +func TestOpenAIWSNextAttemptMessageKeepsInitialMessageForFirstTurnFailover(t *testing.T) { + firstMessage := []byte(`{"type":"response.create","input":"first"}`) + + next, ok := openAIWSNextAttemptMessage(firstMessage, nil, false) + + require.True(t, ok) + require.Equal(t, firstMessage, next) +} diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index da41bf6aa3..18e1d59195 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -271,7 +271,11 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { var imageUpstreamErr *service.OpenAIImagesUpstreamError if errors.As(err, &imageUpstreamErr) { retryableServerError := service.IsOpenAIImagesRetryableUpstreamError(imageUpstreamErr) - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), !retryableServerError, nil) + if retryableServerError { + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), false, nil, err) + } else { + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), true, nil) + } logEvent := "openai.images.upstream_user_error" if retryableServerError { logEvent = "openai.images.upstream_server_error_after_flush" @@ -287,7 +291,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), false, nil, err) if service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) != writerSizeBeforeForward { reqLog.Warn("openai.images.upstream_failover_skipped_after_flush", zap.Int64("account_id", account.ID), @@ -304,19 +308,21 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { return } if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { + retryLimit := effectiveSameAccountRetryLimit(failoverErr, account) + if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai.images.pool_mode_same_account_retry", zap.Int64("account_id", account.ID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("retry_limit", retryLimit), zap.Int("retry_count", sameAccountRetryCount[account.ID]), + zap.Duration("retry_delay", retryDelay), ) select { case <-requestCtx.Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } @@ -341,7 +347,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { ) continue } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), false, nil, err) upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err) wroteFallback := false if !upstreamErrorAlreadyCommunicated { @@ -366,9 +372,9 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, result.FirstTokenMs) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), true, result.FirstTokenMs) } else { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, nil) + h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestModel, false, result), true, nil) } userAgent := c.GetHeader("User-Agent") diff --git a/backend/internal/handler/openai_live.go b/backend/internal/handler/openai_live.go index 0e4a5af45b..1f6b6c49a5 100644 --- a/backend/internal/handler/openai_live.go +++ b/backend/internal/handler/openai_live.go @@ -15,6 +15,7 @@ import ( coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -29,7 +30,7 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) { h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") return } - if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI { + if apiKey.Group == nil || (apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite) { h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform") return } @@ -43,6 +44,18 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) { return } model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String()) + if !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live only supports OpenAI models for Composite groups") + return + } + if upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && upstreamModel != model { + rewrittenSession, rewriteErr := sjson.SetBytes(request.Session, "model", upstreamModel) + if rewriteErr != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply Composite model route") + return + } + request.Session = rewrittenSession + } reqLog := requestLogger( c, "handler.openai_gateway.live", @@ -231,6 +244,6 @@ func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) { func liveEnabledForAPIKey(apiKey *service.APIKey) bool { return apiKey != nil && apiKey.Group != nil && - apiKey.Group.Platform == service.PlatformOpenAI && + (apiKey.Group.Platform == service.PlatformOpenAI || apiKey.Group.Platform == service.PlatformComposite) && apiKey.Group.AllowLive } diff --git a/backend/internal/handler/openai_live_test.go b/backend/internal/handler/openai_live_test.go index 9df49654c8..93fc10fff9 100644 --- a/backend/internal/handler/openai_live_test.go +++ b/backend/internal/handler/openai_live_test.go @@ -87,6 +87,9 @@ func TestLiveEnabledForAPIKey(t *testing.T) { require.True(t, liveEnabledForAPIKey(&service.APIKey{ Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true}, })) + require.True(t, liveEnabledForAPIKey(&service.APIKey{ + Group: &service.Group{Platform: service.PlatformComposite, AllowLive: true}, + })) } func TestLiveAttestationErrorIsExplicit(t *testing.T) { diff --git a/backend/internal/handler/openai_profit_slot_recheck_test.go b/backend/internal/handler/openai_profit_slot_recheck_test.go index b40fbc4e2c..31e251c08a 100644 --- a/backend/internal/handler/openai_profit_slot_recheck_test.go +++ b/backend/internal/handler/openai_profit_slot_recheck_test.go @@ -5,8 +5,7 @@ package handler // 槽位终检与生图跳门回归(handler 半程): // - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选, // 不写响应、不绑定粘连; -// - openAIResponsesRequiredCapability 的生图意图映射钉死(scheduler 的 -// 跳门条件依赖 CapabilityResponses ⇔ 显式生图意图这一耦合)。 +// - openAIResponsesRequiredCapability 的请求能力映射覆盖生图与原生远程压缩。 import ( "context" @@ -141,10 +140,10 @@ func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) { }) } -// scheduler 跳门条件依赖"CapabilityResponses 仅在显式生图意图时被要求"这一 -// 映射;后续若扩展该 capability 的用途,本测试失败提示同步收窄跳门条件。 -func TestOpenAIResponsesRequiredCapabilityPinsImageIntentMapping(t *testing.T) { +func TestOpenAIResponsesRequiredCapabilityForRequest(t *testing.T) { require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapability(true, service.PlatformOpenAI)) require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(false, service.PlatformOpenAI)) require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(true, service.PlatformGrok)) + require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapabilityForRequest(false, true, service.PlatformOpenAI)) + require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapabilityForRequest(false, true, service.PlatformGrok)) } diff --git a/backend/internal/handler/openai_responses_input_tokens_test.go b/backend/internal/handler/openai_responses_input_tokens_test.go new file mode 100644 index 0000000000..b3260fc2ad --- /dev/null +++ b/backend/internal/handler/openai_responses_input_tokens_test.go @@ -0,0 +1,21 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResponsesInputTokensIsClassifiedAsTokenCountRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + require.True(t, isCountTokensRequest(c)) + require.True(t, isTokenCountRequestPath("/responses/input_tokens")) + require.False(t, isTokenCountRequestPath("/v1/responses")) +} diff --git a/backend/internal/handler/openai_service_tier_validation_test.go b/backend/internal/handler/openai_service_tier_validation_test.go new file mode 100644 index 0000000000..97da6f682c --- /dev/null +++ b/backend/internal/handler/openai_service_tier_validation_test.go @@ -0,0 +1,94 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 非法 service_tier 必须在两个 OpenAI 端点(/v1/responses、/v1/chat/completions) +// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier +// 校验处短路,不会进入账号选择/重试。 +// +// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由 +// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler +// 测试走入真实账号选择/重试路径。 + +func newServiceTierHandlerTest(t *testing.T) *OpenAIGatewayHandler { + t.Helper() + return &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil), + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService( + &helperConcurrencyCacheStub{userSeq: []bool{true}}, + )}, + cfg: &config.Config{}, + imageLimiter: &imageConcurrencyLimiter{}, + } +} + +func runOpenAIHandlerServiceTierTest(t *testing.T, path, body string, handler func(h *OpenAIGatewayHandler, c *gin.Context)) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + groupID := int64(6401) + userID := int64(6402) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 6403, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, + User: &service.User{ID: userID, Status: service.StatusActive}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1}) + + handler(newServiceTierHandlerTest(t), c) + return rec +} + +func TestOpenAIGatewayHandlerResponses_InvalidServiceTierRejected400(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","input":"hi","service_tier":"turbo"}`, + `{"model":"gpt-5.5","input":"hi","service_tier":"SPEED"}`, + `{"model":"gpt-5.5","input":"hi","service_tier":""}`, + `{"model":"gpt-5.5","input":"hi","service_tier":123}`, + `{"model":"gpt-5.5","input":"hi","service_tier":{}}`, + } { + rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.Responses(c) + }) + require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) + } +} + +func TestOpenAIGatewayHandlerChatCompletions_InvalidServiceTierRejected400(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"turbo"}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"ultra"}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":""}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":["priority"]}`, + } { + rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.ChatCompletions(c) + }) + require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) + } +} diff --git a/backend/internal/handler/openai_ws_turn_pricing_test.go b/backend/internal/handler/openai_ws_turn_pricing_test.go index b9bb204fd6..3a59f323ba 100644 --- a/backend/internal/handler/openai_ws_turn_pricing_test.go +++ b/backend/internal/handler/openai_ws_turn_pricing_test.go @@ -7,16 +7,20 @@ import ( "github.com/stretchr/testify/require" ) -// TestOpenAIWSTurnPricingZeroValue 钉死 WS turn 定价的零值语义: -// 没有 turn 起始回调的 ingress 模式(ws_v2 passthrough 只实现 AfterTurn) -// 必须让 pricingAt 保持零,由 RecordUsage 回退到记录时刻。 -// -// 反例(本 PR 引入的回归):用建连时刻初始化,会把透传连接的所有 turn 钉死在 -// 建连时的高峰因子——客户端峰前一分钟建连并保活,整条连接就按谷价结算。 -func TestOpenAIWSTurnPricingZeroValue(t *testing.T) { - var p openAIWSTurnPricing - require.True(t, p.current().IsZero(), - "未经 turn 起始回调冻结时必须保持零值,交由 RecordUsage 回退记录时刻") +func TestOpenAIWSTurnPricingCurrentOr(t *testing.T) { + fallback := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC) + + t.Run("frozen time takes precedence", func(t *testing.T) { + frozen := fallback.Add(time.Minute) + var p openAIWSTurnPricing + p.freeze(frozen) + require.Equal(t, frozen, p.currentOr(fallback)) + }) + + t.Run("zero value falls back to turn start", func(t *testing.T) { + var p openAIWSTurnPricing + require.Equal(t, fallback, p.currentOr(fallback)) + }) } // TestOpenAIWSTurnPricingFreezePerTurn 钉死每个 turn 的 BeforeTurn 都会覆盖 @@ -27,8 +31,8 @@ func TestOpenAIWSTurnPricingFreezePerTurn(t *testing.T) { turn2 := time.Now() p.freeze(turn1) - require.Equal(t, turn1, p.current()) + require.Equal(t, turn1, p.currentOr(time.Time{})) p.freeze(turn2) - require.Equal(t, turn2, p.current(), "后续 turn 必须使用自己的定价时刻") + require.Equal(t, turn2, p.currentOr(time.Time{}), "后续 turn 必须使用自己的定价时刻") } diff --git a/backend/internal/handler/ops_capture_writer_nil_test.go b/backend/internal/handler/ops_capture_writer_nil_test.go index 88aa7c043f..8b6cd6533f 100644 --- a/backend/internal/handler/ops_capture_writer_nil_test.go +++ b/backend/internal/handler/ops_capture_writer_nil_test.go @@ -12,9 +12,40 @@ import ( "github.com/stretchr/testify/require" ) +type blockingOpsResponseWriter struct { + gin.ResponseWriter + writeStarted chan struct{} + writeRelease chan struct{} +} + +func (w *blockingOpsResponseWriter) WriteString(s string) (int, error) { + close(w.writeStarted) + <-w.writeRelease + return w.ResponseWriter.WriteString(s) +} + +type deterministicOpsCaptureWriterStatePool struct { + states []*opsCaptureWriterState +} + +func (p *deterministicOpsCaptureWriterStatePool) Get() any { + if len(p.states) == 0 { + return &opsCaptureWriterState{limit: opsCaptureWriterLimit} + } + last := len(p.states) - 1 + state := p.states[last] + p.states = p.states[:last] + return state +} + +func (p *deterministicOpsCaptureWriterStatePool) Put(value any) { + if state, ok := value.(*opsCaptureWriterState); ok && state != nil { + p.states = append(p.states, state) + } +} + func TestOpsCaptureWriter_NilInnerWriter_NoPanic(t *testing.T) { w := &opsCaptureWriter{} - w.ResponseWriter = nil assert.NotPanics(t, func() { assert.Equal(t, 0, w.Status()) @@ -88,3 +119,86 @@ func TestOpsCaptureWriter_CompactKeepaliveRestoresOriginalWriter(t *testing.T) { require.Equal(t, http.StatusOK, outerStatus) require.Equal(t, http.StatusOK, recorder.Code) } + +func TestOpsCaptureWriter_StaleLeaseCannotReachReacquiredState(t *testing.T) { + gin.SetMode(gin.TestMode) + pool := &deterministicOpsCaptureWriterStatePool{} + + firstRecorder := httptest.NewRecorder() + firstContext, _ := gin.CreateTestContext(firstRecorder) + stale := acquireOpsCaptureWriterFromPool(pool, firstContext.Writer) + releaseOpsCaptureWriter(stale) + + secondRecorder := httptest.NewRecorder() + secondContext, _ := gin.CreateTestContext(secondRecorder) + current := acquireOpsCaptureWriterFromPool(pool, secondContext.Writer) + defer releaseOpsCaptureWriter(current) + require.NotSame(t, stale, current) + require.Same(t, stale.state, current.state) + + current.WriteHeader(http.StatusInternalServerError) + _, err := current.WriteString("current") + require.NoError(t, err) + require.Equal(t, []byte("current"), current.capturedBytes()) + + n, err := stale.WriteString("stale") + require.NoError(t, err) + require.Zero(t, n) + require.Nil(t, stale.capturedBytes()) + require.Equal(t, []byte("current"), current.capturedBytes()) + require.NotContains(t, secondRecorder.Body.String(), "stale") + + // Releasing the stale handle must not return an active state to the pool. + releaseOpsCaptureWriter(stale) + thirdRecorder := httptest.NewRecorder() + thirdContext, _ := gin.CreateTestContext(thirdRecorder) + other := acquireOpsCaptureWriterFromPool(pool, thirdContext.Writer) + defer releaseOpsCaptureWriter(other) + require.NotSame(t, current.state, other.state) +} + +func TestOpsCaptureWriter_ReleaseWaitsForDelegatedWriteWithoutHoldingStateMutex(t *testing.T) { + gin.SetMode(gin.TestMode) + pool := &deterministicOpsCaptureWriterStatePool{} + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + inner := &blockingOpsResponseWriter{ + ResponseWriter: ctx.Writer, + writeStarted: make(chan struct{}), + writeRelease: make(chan struct{}), + } + w := acquireOpsCaptureWriterFromPool(pool, inner) + + writeDone := make(chan struct{}) + go func() { + defer close(writeDone) + _, _ = w.WriteString("body") + }() + <-inner.writeStarted + + if !w.state.mu.TryLock() { + t.Fatal("state mutex remained held across the delegated network write") + } + w.state.mu.Unlock() + + releaseDone := make(chan struct{}) + go func() { + releaseOpsCaptureWriter(w) + close(releaseDone) + }() + select { + case <-releaseDone: + t.Fatal("release returned while a delegated write was still active") + case <-time.After(20 * time.Millisecond): + } + require.Empty(t, pool.states) + + close(inner.writeRelease) + <-writeDone + select { + case <-releaseDone: + case <-time.After(time.Second): + t.Fatal("release did not finish after the delegated write returned") + } + require.Len(t, pool.states, 1) +} diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 8be450b16f..41a1ea0583 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -30,8 +30,9 @@ const ( opsStreamKey = "ops_stream" opsAccountIDKey = "ops_account_id" opsRoutingCapacityLimitedKey = "ops_routing_capacity_limited" + opsDedicatedErrorRecordedKey = "ops_dedicated_error_recorded" - opsUpstreamModelKey = "ops_upstream_model" + opsUpstreamModelKey = service.OpsUpstreamModelKey opsRequestTypeKey = "ops_request_type" // 错误过滤匹配常量 — shouldSkipOpsErrorLog 和错误分类共用 @@ -467,6 +468,7 @@ func setOpsSelectedAccount(c *gin.Context, accountID int64, platform ...string) if c == nil || accountID <= 0 { return } + service.ClearOpsUpstreamModel(c) c.Set(opsAccountIDKey, accountID) if c.Request != nil { ctx := context.WithValue(c.Request.Context(), ctxkey.AccountID, accountID) @@ -517,171 +519,566 @@ func isOpsNoAvailableAccountError(err error) bool { } type opsCaptureWriter struct { - gin.ResponseWriter - limit int - buf bytes.Buffer - ctx *gin.Context + // Handles are never pooled. A generation binds each handle to exactly one + // pooled state lease, so a stale handle cannot reach a later request. + state *opsCaptureWriterState + generation uint64 + pool opsCaptureWriterStatePool } -const opsCaptureWriterLimit = service.OpsErrorLogQueueBodyMaxBytes +type opsCaptureWriterState struct { + mu sync.RWMutex + inFlight sync.WaitGroup + generation uint64 + responseWriter gin.ResponseWriter + limit int + buf bytes.Buffer + probe []byte + lineProbe []byte + frameLineLen int + frameTruncated bool + lineTruncated bool + skipLF bool + sseCapturing bool + terminalError parsedOpsError + terminalFound bool + ctx *gin.Context +} + +const ( + opsCaptureWriterLimit = service.OpsErrorLogQueueBodyMaxBytes + opsTerminalSSEFrameProbeLimit = 16 * 1024 +) const opsCaptureWriterPoolMaxRetainedCapacity = service.OpsErrorLogQueueBodyMaxBytes -var opsCaptureWriterPool = sync.Pool{ +type opsCaptureWriterStatePool interface { + Get() any + Put(any) +} + +var opsCaptureWriterPool opsCaptureWriterStatePool = &sync.Pool{ New: func() any { - return &opsCaptureWriter{limit: opsCaptureWriterLimit} + return &opsCaptureWriterState{limit: opsCaptureWriterLimit} }, } func acquireOpsCaptureWriter(rw gin.ResponseWriter) *opsCaptureWriter { - w, ok := opsCaptureWriterPool.Get().(*opsCaptureWriter) - if !ok || w == nil { - w = &opsCaptureWriter{} + return acquireOpsCaptureWriterFromPool(opsCaptureWriterPool, rw) +} + +func acquireOpsCaptureWriterFromPool(pool opsCaptureWriterStatePool, rw gin.ResponseWriter) *opsCaptureWriter { + var pooled any + if pool != nil { + pooled = pool.Get() } - w.ResponseWriter = rw - w.limit = opsCaptureWriterLimit - w.buf.Reset() - return w + state, ok := pooled.(*opsCaptureWriterState) + if !ok || state == nil { + state = &opsCaptureWriterState{} + } + state.mu.Lock() + state.generation++ + state.responseWriter = rw + state.limit = opsCaptureWriterLimit + state.buf.Reset() + state.probe = state.probe[:0] + state.lineProbe = state.lineProbe[:0] + state.frameLineLen = 0 + state.frameTruncated = false + state.lineTruncated = false + state.skipLF = false + state.sseCapturing = false + state.terminalError = parsedOpsError{} + state.terminalFound = false + state.ctx = nil + generation := state.generation + state.mu.Unlock() + return &opsCaptureWriter{state: state, generation: generation, pool: pool} } func releaseOpsCaptureWriter(w *opsCaptureWriter) { - if w == nil { + if w == nil || w.state == nil { return } - w.ResponseWriter = nil - w.ctx = nil - w.limit = opsCaptureWriterLimit - if !shouldPoolOpsCaptureWriter(w) { + state := w.state + state.mu.Lock() + if state.generation != w.generation { + state.mu.Unlock() return } - w.buf.Reset() - opsCaptureWriterPool.Put(w) -} - -func shouldPoolOpsCaptureWriter(w *opsCaptureWriter) bool { - return w != nil && w.buf.Cap() <= opsCaptureWriterPoolMaxRetainedCapacity -} - -func (w *opsCaptureWriter) Status() int { - if w.ResponseWriter == nil { - return 0 + // Invalidate the lease before waiting. No new delegated calls can start for + // this handle, while calls that already copied the writer keep it alive via + // inFlight until their network operation returns. + state.generation++ + state.responseWriter = nil + state.ctx = nil + state.mu.Unlock() + state.inFlight.Wait() + state.mu.Lock() + state.limit = opsCaptureWriterLimit + state.probe = state.probe[:0] + state.lineProbe = state.lineProbe[:0] + state.frameLineLen = 0 + state.frameTruncated = false + state.lineTruncated = false + state.skipLF = false + state.sseCapturing = false + state.terminalError = parsedOpsError{} + state.terminalFound = false + poolable := shouldPoolOpsCaptureWriterState(state) + state.buf.Reset() + state.mu.Unlock() + if poolable && w.pool != nil { + w.pool.Put(state) } - return w.ResponseWriter.Status() } -func (w *opsCaptureWriter) Size() int { - if w.ResponseWriter == nil { - return -1 - } - return w.ResponseWriter.Size() +func shouldPoolOpsCaptureWriterState(state *opsCaptureWriterState) bool { + return state != nil && state.buf.Cap() <= opsCaptureWriterPoolMaxRetainedCapacity && + cap(state.probe) <= opsTerminalSSEFrameProbeLimit && cap(state.lineProbe) <= 256 } -func (w *opsCaptureWriter) Written() bool { - if w.ResponseWriter == nil { - return false +func (w *opsCaptureWriter) lockActive() (*opsCaptureWriterState, gin.ResponseWriter) { + if w == nil || w.state == nil { + return nil, nil } - return w.ResponseWriter.Written() + state := w.state + state.mu.RLock() + if state.generation != w.generation || state.responseWriter == nil { + state.mu.RUnlock() + return nil, nil + } + return state, state.responseWriter +} + +func (w *opsCaptureWriter) lockActiveWrite() (*opsCaptureWriterState, gin.ResponseWriter) { + if w == nil || w.state == nil { + return nil, nil + } + state := w.state + state.mu.Lock() + if state.generation != w.generation || state.responseWriter == nil { + state.mu.Unlock() + return nil, nil + } + return state, state.responseWriter +} + +func (w *opsCaptureWriter) beginDelegatedCall() (*opsCaptureWriterState, gin.ResponseWriter) { + if w == nil || w.state == nil { + return nil, nil + } + state := w.state + state.mu.Lock() + if state.generation != w.generation || state.responseWriter == nil { + state.mu.Unlock() + return nil, nil + } + rw := state.responseWriter + state.inFlight.Add(1) + return state, rw +} + +func finishDelegatedCall(state *opsCaptureWriterState) { + if state != nil { + state.inFlight.Done() + } +} + +func (w *opsCaptureWriter) setContext(ctx *gin.Context) { + state, _ := w.lockActiveWrite() + if state == nil { + return + } + state.ctx = ctx + state.mu.Unlock() +} + +func (w *opsCaptureWriter) capturedBytes() []byte { + state, _ := w.lockActive() + if state == nil { + return nil + } + defer state.mu.RUnlock() + return append([]byte(nil), state.buf.Bytes()...) +} + +func (w *opsCaptureWriter) capturedTerminalError() (parsedOpsError, bool) { + state, _ := w.lockActive() + if state == nil { + return parsedOpsError{}, false + } + defer state.mu.RUnlock() + return state.terminalError, state.terminalFound +} + +func (w *opsCaptureWriter) finalizeCapture() { + state, _ := w.lockActiveWrite() + if state == nil { + return + } + defer state.mu.Unlock() + state.finalizeResponseCapture() } func (w *opsCaptureWriter) Header() http.Header { - if w.ResponseWriter == nil { + state, rw := w.lockActive() + if state == nil { return http.Header{} } - return w.ResponseWriter.Header() + defer state.mu.RUnlock() + return rw.Header() } - func (w *opsCaptureWriter) WriteHeader(code int) { - if w.ResponseWriter == nil { + state, rw := w.beginDelegatedCall() + if state == nil { return } - w.ResponseWriter.WriteHeader(code) + state.mu.Unlock() + defer finishDelegatedCall(state) + rw.WriteHeader(code) } - func (w *opsCaptureWriter) WriteHeaderNow() { - if w.ResponseWriter == nil { + state, rw := w.beginDelegatedCall() + if state == nil { return } - w.ResponseWriter.WriteHeaderNow() + state.mu.Unlock() + defer finishDelegatedCall(state) + rw.WriteHeaderNow() +} +func (w *opsCaptureWriter) Status() int { + state, rw := w.lockActive() + if state == nil { + return 0 + } + defer state.mu.RUnlock() + return rw.Status() +} +func (w *opsCaptureWriter) Size() int { + state, rw := w.lockActive() + if state == nil { + return -1 + } + defer state.mu.RUnlock() + return rw.Size() +} +func (w *opsCaptureWriter) Written() bool { + state, rw := w.lockActive() + if state == nil { + return false + } + defer state.mu.RUnlock() + return rw.Written() } - func (w *opsCaptureWriter) Flush() { - if w.ResponseWriter == nil { + state, rw := w.beginDelegatedCall() + if state == nil { return } - w.ResponseWriter.Flush() + state.mu.Unlock() + defer finishDelegatedCall(state) + rw.Flush() } - func (w *opsCaptureWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - if w.ResponseWriter == nil { + state, rw := w.beginDelegatedCall() + if state == nil { return nil, nil, errors.New("response writer released") } - return w.ResponseWriter.Hijack() + state.mu.Unlock() + defer finishDelegatedCall(state) + return rw.Hijack() } - func (w *opsCaptureWriter) CloseNotify() <-chan bool { - if w.ResponseWriter == nil { + state, rw := w.lockActive() + if state == nil { ch := make(chan bool) close(ch) return ch } - return w.ResponseWriter.CloseNotify() + defer state.mu.RUnlock() + return rw.CloseNotify() } - func (w *opsCaptureWriter) Pusher() http.Pusher { - if w.ResponseWriter == nil { + state, rw := w.lockActive() + if state == nil { return nil } - return w.ResponseWriter.Pusher() + defer state.mu.RUnlock() + return rw.Pusher() } func (w *opsCaptureWriter) Write(b []byte) (int, error) { - if w.ResponseWriter == nil { + state, rw := w.beginDelegatedCall() + if state == nil { return 0, nil } - if w.shouldCapture() && w.Status() >= 400 && w.limit > 0 && w.buf.Len() < w.limit { - remaining := w.limit - w.buf.Len() - if len(b) > remaining { - _, _ = w.buf.Write(b[:remaining]) - } else { - _, _ = w.buf.Write(b) - } + if state.shouldCapture() { + state.captureResponseChunk(b, rw.Status()) } - return w.ResponseWriter.Write(b) + state.mu.Unlock() + defer finishDelegatedCall(state) + return rw.Write(b) } func (w *opsCaptureWriter) WriteString(s string) (int, error) { - if w.ResponseWriter == nil { + state, rw := w.beginDelegatedCall() + if state == nil { return 0, nil } - if w.shouldCapture() && w.Status() >= 400 && w.limit > 0 && w.buf.Len() < w.limit { - remaining := w.limit - w.buf.Len() - if len(s) > remaining { - _, _ = w.buf.WriteString(s[:remaining]) - } else { - _, _ = w.buf.WriteString(s) - } + if state.shouldCapture() { + state.captureResponseChunk([]byte(s), rw.Status()) } - return w.ResponseWriter.WriteString(s) + state.mu.Unlock() + defer finishDelegatedCall(state) + return rw.WriteString(s) } -func (w *opsCaptureWriter) shouldCapture() bool { - if w.ctx == nil { +var _ gin.ResponseWriter = (*opsCaptureWriter)(nil) + +func isOpsTerminalSSEFrame(frame []byte) bool { + eventType, payload := parseOpsSSEFrameEnvelope(frame) + if bytes.Equal(eventType, []byte("response.failed")) || bytes.Equal(eventType, []byte("error")) { return true } - _, rejected := middleware2.GetIngressRejectReason(w.ctx) + if len(payload) == 0 { + return false + } + // Most successful frames cannot be terminal. Avoid JSON decoding on this + // hot path while still validating any plausible terminal payload below. + if !bytes.Contains(payload, []byte("response.failed")) && !bytes.Contains(payload, []byte(`"error"`)) { + return false + } + var event struct { + Type string `json:"type"` + } + return json.Unmarshal(payload, &event) == nil && + (event.Type == "response.failed" || event.Type == "error") +} + +func parseOpsSSEFrameEnvelope(frame []byte) ([]byte, []byte) { + var eventType []byte + var data []byte + dataOwned := false + dataSeen := false + for len(frame) > 0 { + line := frame + lf := bytes.IndexByte(frame, '\n') + cr := bytes.IndexByte(frame, '\r') + idx := lf + if idx < 0 || (cr >= 0 && cr < idx) { + idx = cr + } + if idx >= 0 { + line = frame[:idx] + consume := idx + 1 + if frame[idx] == '\r' && consume < len(frame) && frame[consume] == '\n' { + consume++ + } + frame = frame[consume:] + } else { + frame = nil + } + if len(line) == 0 || line[0] == ':' { + continue + } + field, value, found := bytes.Cut(line, []byte{':'}) + if !found { + value = nil + } + field = bytes.TrimSpace(field) + value = bytes.TrimSpace(value) + switch { + case bytes.Equal(field, []byte("event")): + eventType = value + case bytes.Equal(field, []byte("data")): + if !dataSeen { + data = value + dataSeen = true + continue + } + if !dataOwned { + data = append([]byte(nil), data...) + dataOwned = true + } + data = append(data, '\n') + data = append(data, value...) + } + } + return bytes.TrimSpace(eventType), data +} + +func (state *opsCaptureWriterState) captureResponseChunk(chunk []byte, status int) { + if state == nil || state.limit <= 0 || len(chunk) == 0 { + return + } + if status >= 400 { + state.appendCapturedResponse(chunk) + return + } + if state.sseCapturing { + state.appendTerminalProbe(chunk) + state.appendCapturedResponse(chunk) + return + } + // Most stream writes contain one or more complete successful SSE frames. + // Skip the byte-wise frame parser when the chunk cannot contain a terminal + // event and leaves no split frame to carry into the next write. + if len(state.probe) == 0 && len(state.lineProbe) == 0 && endsAtOpsSSEFrameBoundary(chunk) && + !mayContainOpsTerminalSSE(chunk) { + return + } + for i, b := range chunk { + if state.skipLF { + state.skipLF = false + if b == '\n' { + continue + } + } + if !state.lineTruncated { + if len(state.lineProbe) < 256 { + state.lineProbe = append(state.lineProbe, b) + } else { + state.lineTruncated = true + } + } + if !state.frameTruncated { + if len(state.probe) < opsTerminalSSEFrameProbeLimit { + state.probe = append(state.probe, b) + } else { + state.frameTruncated = true + } + } + if b != '\n' && b != '\r' { + state.frameLineLen++ + continue + } + if b == '\r' { + state.skipLF = true + } + if !state.lineTruncated && isOpsTerminalSSEEventLine(state.lineProbe) { + state.sseCapturing = true + state.terminalError = parsedOpsError{ErrorType: "upstream_error", StreamFailure: true} + state.terminalFound = true + state.appendCapturedResponse(state.lineProbe) + state.probe = state.probe[:0] + state.appendTerminalProbe(state.lineProbe) + state.lineProbe = state.lineProbe[:0] + state.frameLineLen = 0 + state.frameTruncated = false + state.lineTruncated = false + state.skipLF = false + state.appendTerminalProbe(chunk[i+1:]) + state.appendCapturedResponse(chunk[i+1:]) + return + } + if state.frameLineLen != 0 { + state.frameLineLen = 0 + state.lineProbe = state.lineProbe[:0] + state.lineTruncated = false + continue + } + if !state.frameTruncated && isOpsTerminalSSEFrame(state.probe) { + state.sseCapturing = true + state.terminalError, state.terminalFound = parseOpsSSEFailure(state.probe) + state.appendCapturedResponse(state.probe) + state.probe = state.probe[:0] + state.appendCapturedResponse(chunk[i+1:]) + return + } + state.probe = state.probe[:0] + state.lineProbe = state.lineProbe[:0] + state.frameTruncated = false + state.lineTruncated = false + } +} + +func endsAtOpsSSEFrameBoundary(chunk []byte) bool { + return bytes.HasSuffix(chunk, []byte("\n\n")) || + bytes.HasSuffix(chunk, []byte("\r\n\r\n")) || + bytes.HasSuffix(chunk, []byte("\r\r")) +} + +func mayContainOpsTerminalSSE(chunk []byte) bool { + if bytes.Contains(chunk, []byte("response.failed")) { + return true + } + return bytes.Contains(chunk, []byte("error")) && + (bytes.Contains(chunk, []byte("event")) || bytes.Contains(chunk, []byte(`"type"`))) +} + +func isOpsTerminalSSEEventLine(line []byte) bool { + line = bytes.TrimSpace(line) + field, value, found := bytes.Cut(line, []byte{':'}) + return found && bytes.Equal(bytes.TrimSpace(field), []byte("event")) && + (bytes.Equal(bytes.TrimSpace(value), []byte("response.failed")) || + bytes.Equal(bytes.TrimSpace(value), []byte("error"))) +} + +func (state *opsCaptureWriterState) appendTerminalProbe(chunk []byte) { + remaining := opsTerminalSSEFrameProbeLimit - len(state.probe) + if remaining <= 0 { + state.frameTruncated = true + return + } + if len(chunk) > remaining { + chunk = chunk[:remaining] + state.frameTruncated = true + } + state.probe = append(state.probe, chunk...) +} + +func (state *opsCaptureWriterState) finalizeResponseCapture() { + if state == nil { + return + } + if state.terminalFound { + if parsed, ok := parseOpsSSEFailure(state.probe); ok { + state.terminalError = parsed + } + return + } + if state.frameTruncated || len(state.probe) == 0 || !isOpsTerminalSSEFrame(state.probe) { + return + } + state.sseCapturing = true + state.appendCapturedResponse(state.probe) + state.terminalError, state.terminalFound = parseOpsSSEFailure(state.probe) + if !state.terminalFound { + state.terminalError = parsedOpsError{ErrorType: "upstream_error", StreamFailure: true} + state.terminalFound = true + } +} + +func (state *opsCaptureWriterState) appendCapturedResponse(chunk []byte) { + remaining := state.limit - state.buf.Len() + if remaining <= 0 { + return + } + if len(chunk) > remaining { + chunk = chunk[:remaining] + } + _, _ = state.buf.Write(chunk) +} + +func (state *opsCaptureWriterState) shouldCapture() bool { + if state.ctx == nil { + return true + } + _, rejected := middleware2.GetIngressRejectReason(state.ctx) return !rejected } // OpsErrorLoggerMiddleware records error responses (status >= 400) into ops_error_logs. // // Notes: -// - It buffers response bodies only when status >= 400 to avoid overhead for successful traffic. +// - It buffers response bodies only for status >= 400 or terminal SSE frames. // - Streaming errors after the response has started (SSE) may still need explicit logging. func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { return func(c *gin.Context) { originalWriter := c.Writer w := acquireOpsCaptureWriter(originalWriter) - w.ctx = c + w.setContext(c) defer func() { // Restore the original writer before returning so outer middlewares // don't observe a pooled wrapper that has been released. @@ -692,6 +1089,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { }() c.Writer = w c.Next() + w.finalizeCapture() if _, rejected := middleware2.GetIngressRejectReason(c); rejected { return @@ -703,279 +1101,41 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { if !ops.IsMonitoringEnabled(c.Request.Context()) { return } + if c.GetBool(opsDedicatedErrorRecordedKey) { + return + } if shouldSkipOpsErrorLogForCyber(c) { return } status := c.Writer.Status() + body := w.capturedBytes() + parsed := parseOpsErrorResponse(body) + if !parsed.StreamFailure { + if terminal, ok := w.capturedTerminalError(); ok { + parsed = terminal + } + } if status < 400 { - // Even when the client request succeeds, we still want to persist upstream error attempts - // (retries/failover) so ops can observe upstream instability that gets "covered" by retries. - var events []*service.OpsUpstreamErrorEvent - if v, ok := c.Get(service.OpsUpstreamErrorsKey); ok { - if arr, ok := v.([]*service.OpsUpstreamErrorEvent); ok && len(arr) > 0 { - events = arr + if parsed.StreamFailure { + status = inferStreamFailureStatus(c, parsed) + } else { + // A marked in-band error is a visible request failure even though its + // wire status is already 200. Otherwise retain recovered attempts as a + // provider-health row whose 2xx status keeps it outside request SLA. + if len(service.GetOpsStreamErrors(c)) > 0 { + logOpsStreamError(c, ops, status) + } else { + logOpsRecoveredUpstream(c, ops, status) } - } - // Also accept single upstream fields set by gateway services (rare for successful requests). - hasUpstreamContext := len(events) > 0 - if !hasUpstreamContext { - if v, ok := c.Get(service.OpsUpstreamStatusCodeKey); ok { - switch t := v.(type) { - case int: - hasUpstreamContext = t > 0 - case int64: - hasUpstreamContext = t > 0 - } - } - } - if !hasUpstreamContext { - if v, ok := c.Get(service.OpsUpstreamErrorMessageKey); ok { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - hasUpstreamContext = true - } - } - } - if !hasUpstreamContext { - if v, ok := c.Get(service.OpsUpstreamErrorDetailKey); ok { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - hasUpstreamContext = true - } - } - } - if !hasUpstreamContext { - // 没有上游错误上下文,但网关可能在已固化的 200 流上就地补发了 SSE 错误帧 - // (如 ping 等待后并发超限、Wait 后二次计费校验失败)。这类失败若不在此补记, - // 会因 wire 状态码为 200 而在错误看板里彻底隐形。 - logOpsStreamError(c, ops, status) return } - - apiKey := getOpsAPIKey(c) - clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string) - - model, _ := c.Get(opsModelKey) - streamV, _ := c.Get(opsStreamKey) - accountIDV, _ := c.Get(opsAccountIDKey) - - var modelName string - if s, ok := model.(string); ok { - modelName = s - } - stream := false - if b, ok := streamV.(bool); ok { - stream = b - } - - // Prefer showing the account that experienced the upstream error (if we have events), - // otherwise fall back to the final selected account (best-effort). - var accountID *int64 - if len(events) > 0 { - if last := events[len(events)-1]; last != nil && last.AccountID > 0 { - v := last.AccountID - accountID = &v - } - } - if accountID == nil { - if v, ok := accountIDV.(int64); ok && v > 0 { - accountID = &v - } - } - - fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path) - platform := resolveOpsPlatform(c.Request.Context(), apiKey, fallbackPlatform) - - requestID := c.Writer.Header().Get("X-Request-Id") - if requestID == "" { - requestID = c.Writer.Header().Get("x-request-id") - } - - // Best-effort backfill single upstream fields from the last event (if present). - var upstreamStatusCode *int - var upstreamErrorMessage *string - var upstreamErrorDetail *string - finalAccountAuth := false - if len(events) > 0 { - last := events[len(events)-1] - if last != nil { - finalAccountAuth = last.Stage == string(service.GatewayFailureStageAccountAuth) - if finalAccountAuth { - code := 0 - upstreamStatusCode = &code - } else if last.UpstreamStatusCode > 0 { - code := last.UpstreamStatusCode - upstreamStatusCode = &code - } - if msg := strings.TrimSpace(last.Message); msg != "" { - upstreamErrorMessage = &msg - } - if detail := strings.TrimSpace(last.Detail); detail != "" { - upstreamErrorDetail = &detail - } - } - } - - if !finalAccountAuth && upstreamStatusCode == nil { - if v, ok := c.Get(service.OpsUpstreamStatusCodeKey); ok { - switch t := v.(type) { - case int: - if t > 0 { - code := t - upstreamStatusCode = &code - } - case int64: - if t > 0 { - code := int(t) - upstreamStatusCode = &code - } - } - } - } - if !finalAccountAuth && upstreamErrorMessage == nil { - if v, ok := c.Get(service.OpsUpstreamErrorMessageKey); ok { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - msg := strings.TrimSpace(s) - upstreamErrorMessage = &msg - } - } - } - if !finalAccountAuth && upstreamErrorDetail == nil { - if v, ok := c.Get(service.OpsUpstreamErrorDetailKey); ok { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - detail := strings.TrimSpace(s) - upstreamErrorDetail = &detail - } - } - } - - // If we still have nothing meaningful, skip. - if upstreamStatusCode == nil && upstreamErrorMessage == nil && upstreamErrorDetail == nil && len(events) == 0 { - return - } - - effectiveUpstreamStatus := 0 - if upstreamStatusCode != nil { - effectiveUpstreamStatus = *upstreamStatusCode - } - - recoveredMsg := "Recovered upstream error" - if finalAccountAuth { - recoveredMsg = "Recovered account authentication failure" - } else if effectiveUpstreamStatus > 0 { - recoveredMsg += " " + strconvItoa(effectiveUpstreamStatus) - } - if upstreamErrorMessage != nil && strings.TrimSpace(*upstreamErrorMessage) != "" { - recoveredMsg += ": " + strings.TrimSpace(*upstreamErrorMessage) - } - recoveredMsg = truncateString(recoveredMsg, 2048) - recoveredPhase, recoveredBusinessLimited, recoveredOwner, recoveredSource := classifyOpsErrorLog( - c, "upstream_error", recoveredMsg, "", effectiveUpstreamStatus, - ) - - entry := &service.OpsInsertErrorLogInput{ - RequestID: requestID, - ClientRequestID: clientRequestID, - - AccountID: accountID, - Platform: platform, - Model: modelName, - RequestPath: func() string { - if c.Request != nil && c.Request.URL != nil { - return c.Request.URL.Path - } - return "" - }(), - Stream: stream, - InboundEndpoint: GetInboundEndpoint(c), - UpstreamEndpoint: GetUpstreamEndpoint(c, platform), - RequestedModel: modelName, - UpstreamModel: func() string { - if v, ok := c.Get(opsUpstreamModelKey); ok { - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - } - return "" - }(), - RequestType: func() *int16 { - if v, ok := c.Get(opsRequestTypeKey); ok { - switch t := v.(type) { - case int16: - return &t - case int: - v16 := int16(t) - return &v16 - } - } - return nil - }(), - UserAgent: c.GetHeader("User-Agent"), - - ErrorPhase: recoveredPhase, - ErrorType: "upstream_error", - // Severity should reflect the upstream failure, not the final client status (200). - Severity: classifyOpsSeverity("upstream_error", effectiveUpstreamStatus), - StatusCode: status, - IsBusinessLimited: recoveredBusinessLimited, - IsCountTokens: isCountTokensRequest(c), - - ErrorMessage: recoveredMsg, - ErrorBody: "", - - ErrorSource: recoveredSource, - ErrorOwner: recoveredOwner, - - UpstreamStatusCode: upstreamStatusCode, - UpstreamErrorMessage: upstreamErrorMessage, - UpstreamErrorDetail: upstreamErrorDetail, - UpstreamErrors: events, - - CreatedAt: time.Now(), - } - applyOpsLatencyFieldsFromContext(c, entry) - applyOpsUpstreamFieldsFromContext(c, entry) - - if apiKey != nil { - entry.APIKeyID = &apiKey.ID - entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8) - if apiKey.User != nil { - entry.UserID = &apiKey.User.ID - } - if apiKey.GroupID != nil { - entry.GroupID = apiKey.GroupID - } - // Prefer group platform if present (more stable than inferring from path). - if apiKey.Group != nil && apiKey.Group.Platform != "" { - entry.Platform = apiKey.Group.Platform - } - } - - var clientIP string - if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" { - clientIP = ip - entry.ClientIP = &clientIP - } - - // Skip logging if a passthrough rule with skip_monitoring=true matched. - if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { - if skip, _ := v.(bool); skip { - return - } - } - - enqueueOpsErrorLog(ops, entry) - return } - body := w.buf.Bytes() - parsed := parseOpsErrorResponse(body) - // Skip logging if a passthrough rule with skip_monitoring=true matched. - if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { - if skip, _ := v.(bool); skip { - return - } + if shouldSkipFinalOpsFailure(c) { + return } // Skip logging if the error should be filtered based on settings @@ -1007,9 +1167,13 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path) platform := resolveOpsPlatform(c.Request.Context(), apiKey, fallbackPlatform) - requestID := c.Writer.Header().Get("X-Request-Id") + requestID, _ := c.Request.Context().Value(ctxkey.RequestID).(string) + requestID = strings.TrimSpace(requestID) if requestID == "" { - requestID = c.Writer.Header().Get("x-request-id") + requestID = c.Writer.Header().Get("X-Request-Id") + if requestID == "" { + requestID = c.Writer.Header().Get("x-request-id") + } } normalizedType := normalizeOpsErrorType(parsed.ErrorType, parsed.Code) @@ -1063,9 +1227,8 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { IsCountTokens: isCountTokensRequest(c), ErrorMessage: parsed.Message, - // Keep the captured error body (already capped at the queue-safe limit) so the - // service layer can sanitize JSON before truncating for storage. - ErrorBody: string(body), + // Sanitize each SSE data payload before the body enters the async queue. + ErrorBody: sanitizeOpsSSEDataForPersistence(body), ErrorSource: errorSource, ErrorOwner: errorOwner, @@ -1073,6 +1236,16 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } applyOpsLatencyFieldsFromContext(c, entry) applyOpsUpstreamFieldsFromContext(c, entry) + if parsed.StreamFailure { + if message := strings.TrimSpace(parsed.Message); message != "" { + entry.UpstreamErrorMessage = &message + } + if status >= 400 { + finalStatus := status + entry.UpstreamStatusCode = &finalStatus + } + } + suppressOpsUpstreamAttributionForLocalModelConfiguration(c, entry) if apiKey != nil { entry.APIKeyID = &apiKey.ID @@ -1100,6 +1273,138 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } } +func logOpsRecoveredUpstream(c *gin.Context, ops *service.OpsService, finalStatus int) { + if c == nil || ops == nil || finalStatus >= 400 { + return + } + + entry := &service.OpsInsertErrorLogInput{StatusCode: finalStatus} + applyOpsUpstreamFieldsFromContext(c, entry) + if len(entry.UpstreamErrors) > 0 { + visibleEvents := make([]*service.OpsUpstreamErrorEvent, 0, len(entry.UpstreamErrors)) + for _, event := range entry.UpstreamErrors { + if event != nil && !event.SkipMonitoring { + visibleEvents = append(visibleEvents, event) + } + } + if len(visibleEvents) == 0 { + return + } + applyOpsUpstreamErrorEvents(entry, visibleEvents) + } + if entry.UpstreamStatusCode == nil && entry.UpstreamErrorMessage == nil && + entry.UpstreamErrorDetail == nil && len(entry.UpstreamErrors) == 0 { + return + } + + lastStatus := 0 + if entry.UpstreamStatusCode != nil { + lastStatus = *entry.UpstreamStatusCode + } + lastStage := "" + for i := len(entry.UpstreamErrors) - 1; i >= 0; i-- { + if event := entry.UpstreamErrors[i]; event != nil { + lastStage = event.Stage + if event.AccountID > 0 { + accountID := event.AccountID + entry.AccountID = &accountID + } + break + } + } + if entry.AccountID == nil { + if accountID, ok := c.Get(opsAccountIDKey); ok { + if value, ok := accountID.(int64); ok && value > 0 { + entry.AccountID = &value + } + } + } + + entry.ErrorPhase = "upstream" + entry.ErrorType = "upstream_error" + entry.ErrorSource = "upstream_http" + entry.ErrorOwner = "provider" + entry.Severity = classifyOpsSeverity(entry.ErrorType, lastStatus) + entry.IsCountTokens = isCountTokensRequest(c) + entry.CreatedAt = time.Now() + entry.ErrorMessage = "Recovered upstream error" + if lastStage == string(service.GatewayFailureStageAccountAuth) { + entry.ErrorPhase = string(service.GatewayFailureStageAccountAuth) + entry.ErrorMessage = "Recovered account authentication failure" + } else if lastStatus > 0 { + entry.ErrorMessage += " " + strconv.Itoa(lastStatus) + } + if entry.UpstreamErrorMessage != nil && strings.TrimSpace(*entry.UpstreamErrorMessage) != "" { + entry.ErrorMessage += ": " + strings.TrimSpace(*entry.UpstreamErrorMessage) + } + entry.ErrorMessage = truncateString(entry.ErrorMessage, 2048) + + if c.Request != nil { + entry.UserAgent = c.GetHeader("User-Agent") + if c.Request.URL != nil { + entry.RequestPath = c.Request.URL.Path + } + if c.Request.Context() != nil { + entry.ClientRequestID, _ = c.Request.Context().Value(ctxkey.ClientRequestID).(string) + entry.RequestID, _ = c.Request.Context().Value(ctxkey.RequestID).(string) + } + } + entry.RequestID = strings.TrimSpace(entry.RequestID) + if entry.RequestID == "" { + entry.RequestID = c.Writer.Header().Get("X-Request-Id") + } + entry.Model = c.GetString(opsModelKey) + entry.RequestedModel = entry.Model + entry.Stream = c.GetBool(opsStreamKey) + entry.InboundEndpoint = GetInboundEndpoint(c) + entry.UpstreamModel = c.GetString(opsUpstreamModelKey) + entry.RequestType = opsRequestTypeFromContext(c) + + apiKey := getOpsAPIKey(c) + fallbackPlatform := guessPlatformFromPath(entry.RequestPath) + requestContext := context.Background() + if c.Request != nil { + requestContext = c.Request.Context() + } + entry.Platform = resolveOpsPlatform(requestContext, apiKey, fallbackPlatform) + entry.UpstreamEndpoint = GetUpstreamEndpoint(c, entry.Platform) + if apiKey != nil { + entry.APIKeyID = &apiKey.ID + entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8) + if apiKey.User != nil { + entry.UserID = &apiKey.User.ID + } + if apiKey.GroupID != nil { + entry.GroupID = apiKey.GroupID + } + if apiKey.Group != nil && apiKey.Group.Platform != "" { + entry.Platform = apiKey.Group.Platform + } + } + if clientIP := strings.TrimSpace(ip.GetClientIP(c)); clientIP != "" { + entry.ClientIP = &clientIP + } + applyOpsLatencyFieldsFromContext(c, entry) + enqueueOpsErrorLog(ops, entry) +} + +func opsRequestTypeFromContext(c *gin.Context) *int16 { + if c == nil { + return nil + } + if value, ok := c.Get(opsRequestTypeKey); ok { + switch typed := value.(type) { + case int16: + result := typed + return &result + case int: + result := int16(typed) + return &result + } + } + return nil +} + // logOpsStreamError 记录一次挂在已固化 HTTP 200 SSE 流上的就地错误。 // 由于 wire 状态码停留在 200,常规的 status>=400 捕获路径永远不会触发; // handleStreamingAwareError 通过 service.MarkOpsStreamError 标记这类错误, @@ -1108,16 +1413,15 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { // 仅在 status<400 且不存在上游错误上下文时调用:上游透传错误已由中间件的 // upstream-context 分支落库,无需在此重复记录。 func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) { - streamErr, ok := service.GetOpsStreamError(c) - if !ok { - return + for _, streamErr := range service.GetOpsStreamErrors(c) { + logOpsStreamErrorValue(c, ops, wireStatus, streamErr) } +} +func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus int, streamErr service.OpsStreamError) { // 命中 skip_monitoring=true 透传规则的请求跳过落库,与其它分支一致。 - if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { - if skip, _ := v.(bool); skip { - return - } + if streamErr.SkipMonitoring || (streamErr.Turn == 0 && shouldSkipFinalOpsFailure(c)) { + return } // 复用与 status>=400 分支相同的设置过滤(context canceled / 无可用账号等)。 @@ -1162,9 +1466,13 @@ func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) fallbackPlatform := guessPlatformFromPath(c.Request.URL.Path) platform := resolveOpsPlatform(c.Request.Context(), apiKey, fallbackPlatform) - requestID := c.Writer.Header().Get("X-Request-Id") + requestID, _ := c.Request.Context().Value(ctxkey.RequestID).(string) + requestID = strings.TrimSpace(requestID) if requestID == "" { - requestID = c.Writer.Header().Get("x-request-id") + requestID = c.Writer.Header().Get("X-Request-Id") + if requestID == "" { + requestID = c.Writer.Header().Get("x-request-id") + } } entry := &service.OpsInsertErrorLogInput{ @@ -1222,6 +1530,10 @@ func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) CreatedAt: time.Now(), } applyOpsLatencyFieldsFromContext(c, entry) + applyOpsUpstreamFieldsFromContext(c, entry) + if streamErr.Turn > 0 { + applyOpsStreamErrorSnapshot(entry, streamErr) + } if apiKey != nil { entry.APIKeyID = &apiKey.ID @@ -1244,12 +1556,80 @@ func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) enqueueOpsErrorLog(ops, entry) } +func applyOpsStreamErrorSnapshot(entry *service.OpsInsertErrorLogInput, streamErr service.OpsStreamError) { + if entry == nil { + return + } + if streamErr.AccountID > 0 { + accountID := streamErr.AccountID + entry.AccountID = &accountID + } + entry.UpstreamModel = strings.TrimSpace(streamErr.UpstreamModel) + entry.UpstreamStatusCode = nil + if streamErr.UpstreamStatus > 0 { + status := streamErr.UpstreamStatus + entry.UpstreamStatusCode = &status + } + entry.UpstreamErrorMessage = nil + if message := strings.TrimSpace(streamErr.UpstreamMessage); message != "" { + entry.UpstreamErrorMessage = &message + } + entry.UpstreamErrorDetail = nil + if detail := strings.TrimSpace(streamErr.UpstreamDetail); detail != "" { + entry.UpstreamErrorDetail = &detail + } + entry.UpstreamErrors = streamErr.UpstreamErrors + lastStage := "" + for i := len(streamErr.UpstreamErrors) - 1; i >= 0; i-- { + if streamErr.UpstreamErrors[i] != nil { + lastStage = streamErr.UpstreamErrors[i].Stage + break + } + } + if lastStage == string(service.GatewayFailureStageAccountAuth) { + entry.ErrorPhase = string(service.GatewayFailureStageAccountAuth) + entry.ErrorOwner = "provider" + entry.ErrorSource = "gateway" + entry.IsBusinessLimited = false + } else if streamErr.UpstreamStatus > 0 || len(streamErr.UpstreamErrors) > 0 { + entry.ErrorPhase = "upstream" + entry.ErrorOwner = "provider" + entry.ErrorSource = "upstream_http" + entry.IsBusinessLimited = false + } +} + +func shouldSkipFinalOpsFailure(c *gin.Context) bool { + if c == nil { + return false + } + if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { + if skip, _ := v.(bool); skip { + return true + } + } + if v, ok := c.Get(service.OpsUpstreamErrorsKey); ok { + if events, ok := v.([]*service.OpsUpstreamErrorEvent); ok { + for i := len(events) - 1; i >= 0; i-- { + if events[i] != nil { + return events[i].SkipMonitoring + } + } + } + } + return false +} + // isCountTokensRequest checks if the request is a count_tokens request func isCountTokensRequest(c *gin.Context) bool { if c == nil || c.Request == nil || c.Request.URL == nil { return false } - return strings.Contains(c.Request.URL.Path, "/count_tokens") + return isTokenCountRequestPath(c.Request.URL.Path) +} + +func isTokenCountRequestPath(path string) bool { + return strings.Contains(path, "/count_tokens") || strings.Contains(path, "/responses/input_tokens") } func applyOpsLatencyFieldsFromContext(c *gin.Context, entry *service.OpsInsertErrorLogInput) { @@ -1300,40 +1680,55 @@ func applyOpsUpstreamFieldsFromContext(c *gin.Context, entry *service.OpsInsertE } if v, ok := c.Get(service.OpsUpstreamErrorsKey); ok { if events, ok := v.([]*service.OpsUpstreamErrorEvent); ok && len(events) > 0 { - entry.UpstreamErrors = events - last := events[len(events)-1] - if last == nil { - return - } - if last.Stage == string(service.GatewayFailureStageAccountAuth) { - code := 0 - entry.UpstreamStatusCode = &code - entry.UpstreamErrorMessage = nil - if message := strings.TrimSpace(last.Message); message != "" { - entry.UpstreamErrorMessage = &message - } - entry.UpstreamErrorDetail = nil - if detail := strings.TrimSpace(last.Detail); detail != "" { - entry.UpstreamErrorDetail = &detail - } - } else { - if entry.UpstreamStatusCode == nil && last.UpstreamStatusCode > 0 { - code := last.UpstreamStatusCode - entry.UpstreamStatusCode = &code - } - if entry.UpstreamErrorMessage == nil && strings.TrimSpace(last.Message) != "" { - message := strings.TrimSpace(last.Message) - entry.UpstreamErrorMessage = &message - } - if entry.UpstreamErrorDetail == nil && strings.TrimSpace(last.Detail) != "" { - detail := strings.TrimSpace(last.Detail) - entry.UpstreamErrorDetail = &detail - } - } + applyOpsUpstreamErrorEvents(entry, events) } } } +func applyOpsUpstreamErrorEvents(entry *service.OpsInsertErrorLogInput, events []*service.OpsUpstreamErrorEvent) { + entry.UpstreamErrors = events + var last *service.OpsUpstreamErrorEvent + for i := len(events) - 1; i >= 0; i-- { + if events[i] != nil { + last = events[i] + break + } + } + if last == nil { + return + } + + entry.UpstreamStatusCode = nil + entry.UpstreamErrorMessage = nil + entry.UpstreamErrorDetail = nil + if last.Stage == string(service.GatewayFailureStageAccountAuth) { + code := 0 + entry.UpstreamStatusCode = &code + } else if last.UpstreamStatusCode > 0 { + code := last.UpstreamStatusCode + entry.UpstreamStatusCode = &code + } + if message := strings.TrimSpace(last.Message); message != "" { + entry.UpstreamErrorMessage = &message + } + if detail := strings.TrimSpace(last.Detail); detail != "" { + entry.UpstreamErrorDetail = &detail + } +} + +func suppressOpsUpstreamAttributionForLocalModelConfiguration(c *gin.Context, entry *service.OpsInsertErrorLogInput) { + if entry == nil || !service.HasOpsClientBusinessLimited(c) || service.OpsClientBusinessLimitedReason(c) != service.OpsClientBusinessLimitedReasonLocalModelConfiguration { + return + } + entry.AccountID = nil + entry.UpstreamEndpoint = "" + entry.UpstreamModel = "" + entry.UpstreamStatusCode = nil + entry.UpstreamErrorMessage = nil + entry.UpstreamErrorDetail = nil + entry.UpstreamErrors = nil +} + func getContextLatencyMs(c *gin.Context, key string) *int64 { if c == nil || strings.TrimSpace(key) == "" { return nil @@ -1362,15 +1757,20 @@ func getContextLatencyMs(c *gin.Context, key string) *int64 { } type parsedOpsError struct { - ErrorType string - Message string - Code string + ErrorType string + Message string + Code string + StatusCode int + StreamFailure bool } func parseOpsErrorResponse(body []byte) parsedOpsError { if len(body) == 0 { return parsedOpsError{} } + if parsed, ok := parseOpsSSEFailure(body); ok { + return parsed + } // Fast path: attempt to decode into a generic map. var m map[string]any @@ -1382,39 +1782,277 @@ func parseOpsErrorResponse(body []byte) parsedOpsError { if errObj, ok := m["error"].(map[string]any); ok { t, _ := errObj["type"].(string) msg, _ := errObj["message"].(string) - // Gemini googleError also uses "error": { code, message, status } - if msg == "" { - if v, ok := errObj["message"]; ok { - msg, _ = v.(string) - } - } if t == "" { - // Gemini error does not have "type" field. t = "api_error" } - // For gemini error, capture numeric code as string for business-limited mapping if needed. - var code string - if v, ok := errObj["code"]; ok { - switch n := v.(type) { - case string: - code = strings.TrimSpace(n) - case float64: - code = strconvItoa(int(n)) - case int: - code = strconvItoa(n) - } + code := opsJSONScalarString(errObj["code"]) + return parsedOpsError{ErrorType: t, Message: msg, Code: code} + } + if errMessage, ok := m["error"].(string); ok && strings.TrimSpace(errMessage) != "" { + t, _ := m["type"].(string) + if t == "" || t == "error" { + t = "api_error" + } + return parsedOpsError{ErrorType: t, Message: strings.TrimSpace(errMessage), Code: opsJSONScalarString(m["code"])} + } + + // APIKeyAuth-style: { code:"INSUFFICIENT_BALANCE", message:"..." } + code := opsJSONScalarString(m["code"]) + msg, _ := m["message"].(string) + if code != "" || msg != "" { + t, _ := m["type"].(string) + if t == "" || t == "error" { + t = "api_error" } return parsedOpsError{ErrorType: t, Message: msg, Code: code} } - // APIKeyAuth-style: { code:"INSUFFICIENT_BALANCE", message:"..." } - code, _ := m["code"].(string) - msg, _ := m["message"].(string) - if code != "" || msg != "" { - return parsedOpsError{ErrorType: "api_error", Message: msg, Code: code} + return parsedOpsError{Message: truncateString(string(body), 1024)} +} + +func opsJSONScalarString(value any) string { + switch value := value.(type) { + case string: + return strings.TrimSpace(value) + case float64: + return strconvItoa(int(value)) + case json.Number: + return strings.TrimSpace(value.String()) + case int: + return strconvItoa(value) + case int64: + return strconv.FormatInt(value, 10) + default: + return "" + } +} + +func opsJSONInt(value any) int { + switch value := value.(type) { + case float64: + return int(value) + case json.Number: + parsed, _ := strconv.Atoi(value.String()) + return parsed + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(value)) + return parsed + case int: + return value + case int64: + return int(value) + default: + return 0 + } +} + +func parseOpsSSEFailure(body []byte) (parsedOpsError, bool) { + normalized := strings.ReplaceAll(string(body), "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + var errorCandidate *parsedOpsError + for _, frame := range strings.Split(normalized, "\n\n") { + frame = strings.TrimSpace(frame) + if frame == "" { + continue + } + eventTypeBytes, payloadBytes := parseOpsSSEFrameEnvelope([]byte(frame)) + eventType := string(eventTypeBytes) + if eventType != "response.failed" && eventType != "error" && len(payloadBytes) == 0 { + continue + } + + payload := string(payloadBytes) + var event map[string]any + if err := json.Unmarshal(payloadBytes, &event); err == nil { + if eventType == "" { + eventType, _ = event["type"].(string) + } + } + if eventType != "response.failed" && eventType != "error" { + continue + } + + parsed := parsedOpsError{ErrorType: "upstream_error", StreamFailure: true} + if eventType == "error" { + parsed.ErrorType = "api_error" + } + errObj := opsSSEErrorObject(event) + if errObj == nil && event != nil && (event["message"] != nil || event["code"] != nil) { + errObj = event + } + if errObj != nil { + parsed.ErrorType, _ = errObj["type"].(string) + if parsed.ErrorType == "error" || parsed.ErrorType == "response.failed" { + parsed.ErrorType = "" + } + parsed.Message, _ = errObj["message"].(string) + switch code := errObj["code"].(type) { + case string: + parsed.Code = strings.TrimSpace(code) + case float64: + parsed.Code = strconvItoa(int(code)) + } + parsed.StatusCode = opsJSONInt(errObj["status_code"]) + if parsed.StatusCode == 0 { + parsed.StatusCode = opsJSONInt(errObj["status"]) + } + if parsed.StatusCode == 0 { + parsed.StatusCode = opsJSONInt(event["status_code"]) + } + if parsed.StatusCode == 0 { + parsed.StatusCode = opsJSONInt(event["status"]) + } + if parsed.ErrorType == "" { + parsed.ErrorType = inferResponsesFailedOpsErrorType(parsed.Code) + } + if parsed.ErrorType == "" { + if eventType == "error" { + parsed.ErrorType = "api_error" + } else { + parsed.ErrorType = "upstream_error" + } + } + } + if strings.TrimSpace(parsed.Message) == "" && payload != "" { + trimmedPayload := strings.TrimSpace(payload) + if strings.HasPrefix(trimmedPayload, "{") || strings.HasPrefix(trimmedPayload, "[") { + parsed.Message = "upstream stream failed" + } else { + parsed.Message = truncateString(trimmedPayload, 1024) + } + } + if eventType == "response.failed" { + return parsed, true + } + candidate := parsed + errorCandidate = &candidate + } + if errorCandidate != nil { + return *errorCandidate, true + } + return parsedOpsError{}, false +} + +func opsSSEErrorObject(event map[string]any) map[string]any { + if event == nil { + return nil + } + if errObj, ok := event["error"].(map[string]any); ok { + return errObj + } + if response, ok := event["response"].(map[string]any); ok { + if errObj, ok := response["error"].(map[string]any); ok { + return errObj + } + } + // Some providers flatten error fields onto the terminal event itself: + // {"type":"error","code":"service_unavailable","message":"..."}. + if eventType, _ := event["type"].(string); eventType == "error" { + return event + } + return nil +} + +func sanitizeOpsSSEDataForPersistence(body []byte) string { + if len(body) == 0 || !bytes.Contains(body, []byte("data")) { + return string(body) + } + normalized := bytes.ReplaceAll(body, []byte("\r\n"), []byte{'\n'}) + normalized = bytes.ReplaceAll(normalized, []byte{'\r'}, []byte{'\n'}) + frames := bytes.Split(normalized, []byte("\n\n")) + var out bytes.Buffer + out.Grow(len(body)) + for frameIndex, frame := range frames { + if frameIndex > 0 { + _, _ = out.WriteString("\n\n") + } + _, payload := parseOpsSSEFrameEnvelope(frame) + trimmedPayload := bytes.TrimSpace(payload) + replacement := "" + if json.Valid(trimmedPayload) { + replacement, _ = service.SanitizeOpsErrorBodyForQueue(string(trimmedPayload)) + } else if len(trimmedPayload) > 0 && (trimmedPayload[0] == '{' || trimmedPayload[0] == '[') { + // Captured terminal frames can be truncated at the queue bound. Never + // persist a JSON-looking fragment that could contain an unredacted key. + replacement = `{"payload_truncated":true}` + } + if replacement == "" { + _, _ = out.Write(frame) + continue + } + wroteData := false + emittedLine := false + for _, line := range bytes.Split(frame, []byte{'\n'}) { + field, _, found := bytes.Cut(line, []byte{':'}) + if found && bytes.Equal(bytes.TrimSpace(field), []byte("data")) { + if wroteData { + continue + } + line = append([]byte("data: "), replacement...) + wroteData = true + } + if emittedLine { + _ = out.WriteByte('\n') + } + _, _ = out.Write(line) + emittedLine = true + } + } + return out.String() +} + +func inferResponsesFailedOpsErrorType(code string) string { + switch strings.TrimSpace(code) { + case "rate_limit_exceeded": + return "rate_limit_error" + case "permission_denied", "permission_error", "insufficient_permissions", "cyber_policy", "content_policy": + return "permission_error" + case "invalid_request", "context_length_exceeded": + return "invalid_request_error" + case "server_is_overloaded": + return "overloaded_error" + case "service_unavailable", "service_unavailable_error", "server_error": + return "service_unavailable_error" + case "authentication_failed": + return "authentication_error" + default: + return "" + } +} + +func inferStreamFailureStatus(_ *gin.Context, parsed parsedOpsError) int { + if parsed.StatusCode >= 400 && parsed.StatusCode <= 599 { + return parsed.StatusCode + } + switch strings.TrimSpace(parsed.Code) { + case "rate_limit_exceeded": + return http.StatusTooManyRequests + case "permission_denied", "permission_error", "insufficient_permissions", "cyber_policy", "content_policy": + return http.StatusForbidden + case "invalid_request", "context_length_exceeded": + return http.StatusBadRequest + case "server_is_overloaded": + return http.StatusServiceUnavailable + case "service_unavailable", "service_unavailable_error", "server_error": + return http.StatusServiceUnavailable + case "authentication_failed": + return http.StatusUnauthorized } - return parsedOpsError{Message: truncateString(string(body), 1024)} + switch strings.TrimSpace(parsed.ErrorType) { + case "rate_limit_error": + return http.StatusTooManyRequests + case "permission_error", "forbidden_error": + return http.StatusForbidden + case "authentication_error": + return http.StatusUnauthorized + case "invalid_request_error": + return http.StatusBadRequest + case "overloaded_error", "service_unavailable_error": + return http.StatusServiceUnavailable + } + + return http.StatusBadGateway } // getOpsAPIKey 返回用于 Ops 错误日志的 API Key:优先取已鉴权写入的正式 key; @@ -1463,11 +2101,15 @@ func isKnownOpsErrorType(t string) bool { switch t { case "invalid_request_error", "authentication_error", + "permission_error", + "model_not_found", + "service_unavailable", "rate_limit_error", "billing_error", "subscription_error", "upstream_error", "overloaded_error", + "service_unavailable_error", "api_error", "not_found_error", "forbidden_error": @@ -1511,7 +2153,7 @@ func classifyOpsPhase(errType, message, code string) string { return "request" } return "upstream" - case "invalid_request_error": + case "invalid_request_error", "permission_error", "forbidden_error", "not_found_error", "model_not_found": return "request" case "upstream_error", "overloaded_error": return "upstream" @@ -1527,7 +2169,7 @@ func classifyOpsPhase(errType, message, code string) string { func classifyOpsSeverity(errType string, status int) string { switch errType { - case "invalid_request_error", "authentication_error", "billing_error", "subscription_error": + case "invalid_request_error", "authentication_error", "permission_error", "forbidden_error", "not_found_error", "model_not_found", "billing_error", "subscription_error": return "P3" } if status >= 500 { @@ -1546,23 +2188,27 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i phase = classifyOpsPhase(errType, message, code) routingCapacityLimited := isOpsRoutingCapacityLimited(c) clientBusinessLimited := service.HasOpsClientBusinessLimited(c) + localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration upstreamError := hasOpsUpstreamErrorContext(c) accountAuthFailure := hasOpsAccountAuthFailure(c) - if accountAuthFailure && !routingCapacityLimited { + if localModelConfiguration { + phase = "routing" + } else if accountAuthFailure && !routingCapacityLimited { phase = "account_auth" } else if upstreamError && !routingCapacityLimited { phase = "upstream" } - if clientBusinessLimited && !upstreamError && !routingCapacityLimited { + if clientBusinessLimited && !upstreamError && !routingCapacityLimited && !localModelConfiguration { phase = "auth" } if routingCapacityLimited { phase = "routing" } msg := strings.ToLower(message) - localClientAuthError := !upstreamError && phase == "auth" && isOpsClientAuthError(code, msg) - localBusinessLimited := !upstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError) - isBusinessLimited = routingCapacityLimited || (clientBusinessLimited && !upstreamError) || localBusinessLimited + effectiveUpstreamError := upstreamError && !localModelConfiguration + localClientAuthError := !effectiveUpstreamError && phase == "auth" && isOpsClientAuthError(code, msg) + localBusinessLimited := !effectiveUpstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError) + isBusinessLimited = localModelConfiguration || routingCapacityLimited || (clientBusinessLimited && !effectiveUpstreamError) || localBusinessLimited errorOwner = classifyOpsErrorOwner(phase, message) errorSource = classifyOpsErrorSource(phase, message) return phase, isBusinessLimited, errorOwner, errorSource @@ -1774,7 +2420,7 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message bodyLower := strings.ToLower(body) // Check if count_tokens errors should be ignored - if settings.IgnoreCountTokensErrors && strings.Contains(requestPath, "/count_tokens") { + if settings.IgnoreCountTokensErrors && isTokenCountRequestPath(requestPath) { return true } diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 3b778cdcf9..640e8efa91 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -9,6 +9,7 @@ import ( "testing" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -37,15 +38,18 @@ func (r *ingressRejectSettingRepo) Set(context.Context, string, string) error { type ingressRejectOpsRepo struct { service.OpsRepository insertCalls int + entries []*service.OpsInsertErrorLogInput } -func (r *ingressRejectOpsRepo) InsertErrorLog(context.Context, *service.OpsInsertErrorLogInput) (int64, error) { +func (r *ingressRejectOpsRepo) InsertErrorLog(_ context.Context, entry *service.OpsInsertErrorLogInput) (int64, error) { r.insertCalls++ + r.entries = append(r.entries, entry) return 0, nil } -func (r *ingressRejectOpsRepo) BatchInsertErrorLogs(context.Context, []*service.OpsInsertErrorLogInput) (int64, error) { +func (r *ingressRejectOpsRepo) BatchInsertErrorLogs(_ context.Context, entries []*service.OpsInsertErrorLogInput) (int64, error) { r.insertCalls++ + r.entries = append(r.entries, entries...) return 0, nil } @@ -187,21 +191,23 @@ func TestOpsCaptureWriterPool_ResetOnRelease(t *testing.T) { writer := acquireOpsCaptureWriter(c.Writer) require.NotNil(t, writer) - _, err := writer.buf.WriteString("temp-error-body") + c.Writer.WriteHeader(http.StatusInternalServerError) + _, err := writer.WriteString("temp-error-body") require.NoError(t, err) + require.NotEmpty(t, writer.capturedBytes()) releaseOpsCaptureWriter(writer) reused := acquireOpsCaptureWriter(c.Writer) defer releaseOpsCaptureWriter(reused) - require.Zero(t, reused.buf.Len(), "writer should be reset before reuse") + require.Empty(t, reused.capturedBytes(), "writer should be reset before reuse") } func TestOpsCaptureWriterPool_DropsLargeBuffers(t *testing.T) { - w := &opsCaptureWriter{} - w.buf.Grow(opsCaptureWriterPoolMaxRetainedCapacity + 1) - require.False(t, shouldPoolOpsCaptureWriter(w)) + state := &opsCaptureWriterState{} + state.buf.Grow(opsCaptureWriterPoolMaxRetainedCapacity + 1) + require.False(t, shouldPoolOpsCaptureWriterState(state)) } func TestEnqueueOpsErrorLog_SanitizesAndBoundsBodyBeforeQueue(t *testing.T) { @@ -279,6 +285,308 @@ func TestOpsErrorLoggerMiddleware_HardSkipsIngressRejection(t *testing.T) { require.Zero(t, OpsErrorLogEnqueuedTotal(), "ingress rejection must not enter the error queue") } +func TestOpsErrorLoggerMiddleware_DedicatedCyberSessionBlockRecordsExactlyOnce(t *testing.T) { + setupOpsErrorLogTestQueue(t, 3) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h := &OpenAIGatewayHandler{opsService: ops} + apiKey := &service.APIKey{ID: 41, Key: "sk-dedicated-test"} + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, "gpt-test", "session-block-hash") + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{ + "type": "permission_error", "code": "session_blocked_by_cyber_policy", "message": "blocked", + }}) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, "cyber_policy_session_blocked", job.entry.ErrorType) + require.Equal(t, http.StatusForbidden, job.entry.StatusCode) +} + +func TestOpsErrorLoggerMiddleware_OrdinaryPermissionStillRecords(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{ + "type": "permission_error", "code": "permission_denied", "message": "denied", + }}) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, "permission_error", job.entry.ErrorType) + require.Equal(t, http.StatusForbidden, job.entry.StatusCode) +} + +func TestOpsErrorLoggerMiddleware_RecordsRecoveredUpstreamTelemetryOutsideFailureSLA(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + + repo := &ingressRejectOpsRepo{} + ops := service.NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + UpstreamStatusCode: http.StatusTooManyRequests, + Message: "earlier attempt was rate limited", + }}) + c.JSON(http.StatusOK, gin.H{"status": "completed"}) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Nil(t, job.entry.UpstreamErrors, "raw attempts must be released before async queueing") + require.NotNil(t, job.entry.UpstreamErrorsJSON) + queuedEvents, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON) + require.NoError(t, err) + require.Len(t, queuedEvents, 1) + require.Equal(t, http.StatusTooManyRequests, queuedEvents[0].UpstreamStatusCode) + + flushOpsErrorLogBatch([]opsErrorLogJob{job}) + require.Equal(t, 1, repo.insertCalls) + require.Len(t, repo.entries, 1) + persisted := repo.entries[0] + require.Equal(t, http.StatusOK, persisted.StatusCode, "recovered telemetry must remain outside failed-request SLA") + require.Equal(t, "upstream", persisted.ErrorPhase) + require.Equal(t, "upstream_error", persisted.ErrorType) + require.Equal(t, "Recovered upstream error 429: earlier attempt was rate limited", persisted.ErrorMessage) + require.NotNil(t, persisted.UpstreamErrorsJSON) + persistedEvents, err := service.ParseOpsUpstreamErrors(*persisted.UpstreamErrorsJSON) + require.NoError(t, err) + require.Len(t, persistedEvents, 1) + require.Equal(t, http.StatusTooManyRequests, persistedEvents[0].UpstreamStatusCode) +} + +func TestOpsErrorLoggerMiddleware_RecoveredTelemetryFiltersSkipMonitoringAttempts(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{ + {UpstreamStatusCode: http.StatusTooManyRequests, Message: "visible retry"}, + {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true}, + }) + c.JSON(http.StatusOK, gin.H{"status": "completed"}) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, "Recovered upstream error 429: visible retry", job.entry.ErrorMessage) + require.NotNil(t, job.entry.UpstreamErrorsJSON) + events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, "visible retry", events[0].Message) +} + +func TestOpsErrorLoggerMiddleware_RecoveredTelemetrySkipsAllHiddenAttempts(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + UpstreamStatusCode: http.StatusTooManyRequests, + Message: "hidden retry", + SkipMonitoring: true, + }}) + c.JSON(http.StatusOK, gin.H{"status": "completed"}) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, int64(0), OpsErrorLogQueueLength()) +} + +func TestOpsErrorLoggerMiddleware_IntermediateSkipMonitoringDoesNotHideFinalVisibleFailure(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{ + {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true}, + {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"}, + }) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{"type": "upstream_error", "message": "visible final"}}) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode) + require.Equal(t, "visible final", job.entry.ErrorMessage) +} + +func TestOpsErrorLoggerMiddleware_CapturesSplitResponsesFailedSSE(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + setOpsRequestContext(c, "gpt-5.5", true) + c.Status(http.StatusOK) + _, _ = c.Writer.Write([]byte("event: response.")) + _, _ = c.Writer.Write([]byte("failed\n")) + _, _ = c.Writer.Write([]byte(`data: {"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Too many pending requests"}}}`)) + _, _ = c.Writer.Write([]byte("\n\n")) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode) + require.Equal(t, "rate_limit_error", job.entry.ErrorType) + require.Contains(t, job.entry.ErrorMessage, "Too many pending requests") +} + +func TestOpsCaptureWriter_CapturesSplitDataOnlyTerminalMarkers(t *testing.T) { + tests := []struct { + name string + prefix string + suffix string + wantType string + wantCode string + wantError string + }{ + { + name: "response failed with space", + prefix: `data: {"type":"response.`, + suffix: `failed","response":{"error":{"code":"server_is_overloaded","message":"busy"}}}`, + wantType: "overloaded_error", + wantCode: "server_is_overloaded", + wantError: "busy", + }, + { + name: "response failed without space", + prefix: `data:{"type":"response.`, + suffix: `failed","error":{"code":"rate_limit_exceeded","message":"slow down"}}`, + wantType: "rate_limit_error", + wantCode: "rate_limit_exceeded", + wantError: "slow down", + }, + { + name: "error with space", + prefix: `data: {"type":"er`, + suffix: `ror","error":{"type":"invalid_request_error","code":"invalid_request","message":"bad input"}}`, + wantType: "invalid_request_error", + wantCode: "invalid_request", + wantError: "bad input", + }, + { + name: "error without space", + prefix: `data:{"type":"er`, + suffix: `ror","error":{"type":"authentication_error","code":"authentication_failed","message":"sign in"}}`, + wantType: "authentication_error", + wantCode: "authentication_failed", + wantError: "sign in", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := &opsCaptureWriterState{limit: opsCaptureWriterLimit} + state.captureResponseChunk([]byte(tt.prefix), http.StatusOK) + require.Empty(t, state.buf.Bytes(), "partial frame must remain in the bounded probe") + state.captureResponseChunk([]byte(tt.suffix+"\n\n"), http.StatusOK) + + parsed := parseOpsErrorResponse(state.buf.Bytes()) + require.True(t, state.sseCapturing) + require.True(t, parsed.StreamFailure) + require.Equal(t, tt.wantType, parsed.ErrorType) + require.Equal(t, tt.wantCode, parsed.Code) + require.Equal(t, tt.wantError, parsed.Message) + require.LessOrEqual(t, len(state.probe), opsTerminalSSEFrameProbeLimit) + }) + } +} + +func TestOpsErrorLoggerMiddleware_StreamFailureUsesTerminalErrorOverAttemptContext(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + service.SetOpsUpstreamError(c, http.StatusBadGateway, "Upstream transport error", "earlier attempt failed") + c.Status(http.StatusOK) + _, _ = c.Writer.WriteString("event: er") + _, _ = c.Writer.WriteString("ror\n") + _, _ = c.Writer.WriteString(`data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input exceeds the context window"}}`) + _, _ = c.Writer.WriteString("\n\n") + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusBadRequest, job.entry.StatusCode) + require.Equal(t, "invalid_request_error", job.entry.ErrorType) + require.NotNil(t, job.entry.UpstreamStatusCode) + require.Equal(t, http.StatusBadRequest, *job.entry.UpstreamStatusCode) + require.NotNil(t, job.entry.UpstreamErrorMessage) + require.Equal(t, "input exceeds the context window", *job.entry.UpstreamErrorMessage) +} + +func TestOpsErrorLoggerMiddleware_PrefersContextRequestID(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Header("X-Request-Id", "response-header-id") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "bad input"}}) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + request = request.WithContext(context.WithValue(request.Context(), ctxkey.RequestID, "context-request-id")) + router.ServeHTTP(recorder, request) + + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, "context-request-id", job.entry.RequestID) +} + func TestNormalizeOpsPersistentUserAgentBoundsAndPreservesUTF8(t *testing.T) { value := strings.Repeat("a", opsErrorLogMaxUserAgentBytes-1) + "你" + strings.Repeat("b", 32) got := normalizeOpsPersistentUserAgent(" " + value + " ") @@ -382,6 +690,23 @@ func TestLogOpsStreamError_SkipWhenPassthroughSkipMonitoring(t *testing.T) { require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal()) } +func TestShouldSkipFinalOpsFailureUsesOnlyFinalAttemptRule(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{ + {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden intermediate", SkipMonitoring: true}, + {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"}, + }) + require.False(t, shouldSkipFinalOpsFailure(c)) + + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{ + {UpstreamStatusCode: http.StatusBadGateway, Message: "visible intermediate"}, + nil, + {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "hidden final", SkipMonitoring: true}, + }) + require.True(t, shouldSkipFinalOpsFailure(c)) +} + // MarkOpsStreamError 采用「首个标记生效」:后续的通用兜底帧不得覆盖根因错误。 func TestMarkOpsStreamError_FirstWins(t *testing.T) { gin.SetMode(gin.TestMode) @@ -398,6 +723,35 @@ func TestMarkOpsStreamError_FirstWins(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, se.IntendedStatus) } +func TestLogOpsStreamError_RecordsOneFailurePerWebSocketTurn(t *testing.T) { + setupOpsErrorLogTestQueue(t, 4) + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + service.SetOpenAIClientTransport(c, service.OpenAIClientTransportWS) + + service.BeginOpsStreamTurn(c, 1) + service.MarkOpsStreamFailure(c, "rate_limit_error", "rate_limit_exceeded", "turn one failed", http.StatusTooManyRequests) + service.MarkOpsStreamError(c, "upstream_error", "generic duplicate for turn one", http.StatusBadGateway) + service.BeginOpsStreamTurn(c, 2) + service.MarkOpsStreamFailure(c, "permission_error", "permission_denied", "turn two failed", http.StatusForbidden) + + streamErrors := service.GetOpsStreamErrors(c) + require.Len(t, streamErrors, 2) + require.Equal(t, 1, streamErrors[0].Turn) + require.Equal(t, 2, streamErrors[1].Turn) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + logOpsStreamError(c, ops, http.StatusSwitchingProtocols) + + require.Equal(t, int64(2), OpsErrorLogQueueLength()) + first := <-opsErrorLogQueue + second := <-opsErrorLogQueue + require.Equal(t, "turn one failed", first.entry.ErrorMessage) + require.Equal(t, http.StatusTooManyRequests, first.entry.StatusCode) + require.Equal(t, "turn two failed", second.entry.ErrorMessage) + require.Equal(t, http.StatusForbidden, second.entry.StatusCode) +} + func TestIsKnownOpsErrorType(t *testing.T) { known := []string{ "invalid_request_error", @@ -497,6 +851,107 @@ func TestClassifyOpsRoutingCapacityMarkerExcludesMaskedSelectionFailureFromSLA(t require.Equal(t, "gateway", errorSource) } +func TestClassifyOpsLocalModelConfigurationRejection(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog( + c, + "model_not_found", + "Model \"gpt-missing\" is not supported by any configured account in this group", + "", + http.StatusNotFound, + ) + + require.Equal(t, "routing", phase) + require.True(t, isBusinessLimited) + require.Equal(t, "platform", errorOwner) + require.Equal(t, "gateway", errorSource) +} + +func TestClassifyOpsLocalModelConfigurationOverridesStaleUpstreamMarkers(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized) + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Stage: string(service.GatewayFailureStageAccountAuth), + UpstreamStatusCode: http.StatusUnauthorized, + }}) + + phase, limited, owner, source := classifyOpsErrorLog(c, "model_not_found", "unsupported configured model", "", http.StatusNotFound) + + require.Equal(t, "routing", phase) + require.True(t, limited) + require.Equal(t, "platform", owner) + require.Equal(t, "gateway", source) +} + +func TestClassifyOpsLocalModelConfigurationRequiresMarkerAndReason(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(service.OpsClientBusinessLimitedReasonKey, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + c.Set(service.OpsUpstreamStatusCodeKey, http.StatusBadGateway) + + phase, limited, owner, source := classifyOpsErrorLog(c, "upstream_error", "provider failed", "", http.StatusBadGateway) + + require.Equal(t, "upstream", phase) + require.False(t, limited) + require.Equal(t, "provider", owner) + require.Equal(t, "upstream_http", source) +} + +func TestOpsErrorLoggerMiddleware_LocalModelConfigurationFields(t *testing.T) { + setupOpsErrorLogTestQueue(t, 1) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/chat/completions", func(c *gin.Context) { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + c.Set(opsAccountIDKey, int64(99)) + c.Set(opsUpstreamModelKey, "stale-upstream-model") + setActualUpstreamEndpoint(c, "/v1/chat/completions") + c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized) + c.Set(service.OpsUpstreamErrorMessageKey, "stale upstream error") + c.Set(service.OpsUpstreamErrorDetailKey, "stale upstream detail") + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Stage: string(service.GatewayFailureStageAccountAuth), + UpstreamStatusCode: http.StatusUnauthorized, + Message: "stale auth failure", + }}) + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "model_not_found", + "message": "Model \"gpt-missing\" is not supported by any configured account in this group", + }, + }) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.JSONEq(t, `{"error":{"type":"model_not_found","message":"Model \"gpt-missing\" is not supported by any configured account in this group"}}`, w.Body.String()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusNotFound, job.entry.StatusCode) + require.Equal(t, "routing", job.entry.ErrorPhase) + require.True(t, job.entry.IsBusinessLimited) + require.Equal(t, "platform", job.entry.ErrorOwner) + require.Equal(t, "gateway", job.entry.ErrorSource) + require.Nil(t, job.entry.AccountID) + require.Nil(t, job.entry.UpstreamStatusCode) + require.Nil(t, job.entry.UpstreamErrors) + require.Nil(t, job.entry.UpstreamErrorMessage) + require.Nil(t, job.entry.UpstreamErrorDetail) + require.Empty(t, job.entry.UpstreamModel) + require.Empty(t, job.entry.UpstreamEndpoint) +} + func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { tests := []struct { name string @@ -621,7 +1076,11 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { errType := normalizeOpsErrorType(tt.errType, tt.code) phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status) - require.Equal(t, "api_error", errType) + wantErrType := "api_error" + if tt.errType == "permission_error" { + wantErrType = "permission_error" + } + require.Equal(t, wantErrType, errType) require.Equal(t, "auth", phase) require.True(t, isBusinessLimited) require.Equal(t, "client", errorOwner) @@ -772,7 +1231,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { message: "This group is restricted to Claude Code clients (/v1/messages only)", code: "", status: http.StatusForbidden, - wantErrType: "api_error", + wantErrType: "permission_error", wantPhase: "request", }, { @@ -781,7 +1240,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { message: "Image generation is not enabled for this group", code: "", status: http.StatusForbidden, - wantErrType: "api_error", + wantErrType: "permission_error", wantPhase: "request", }, { @@ -808,7 +1267,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { message: "model claude-3-5-sonnet not in whitelist", code: "", status: http.StatusForbidden, - wantErrType: "api_error", + wantErrType: "permission_error", wantPhase: "request", }, { @@ -835,7 +1294,7 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) { message: "openai service_tier=priority is not allowed for model gpt-5.5", code: "", status: http.StatusForbidden, - wantErrType: "api_error", + wantErrType: "permission_error", wantPhase: "request", }, { @@ -1146,6 +1605,254 @@ func TestParseOpsErrorResponsePreservesNestedStringCode(t *testing.T) { require.Equal(t, "API Key 所属分组已删除", parsed.Message) } +func TestParseOpsErrorResponsePreservesStructuredTopLevelSemantics(t *testing.T) { + tests := []struct { + name string + body string + wantType string + wantCode string + wantMsg string + }{ + { + name: "model not found", + body: `{"type":"model_not_found","code":404,"message":"model unavailable"}`, + wantType: "model_not_found", + wantCode: "404", + wantMsg: "model unavailable", + }, + { + name: "string error", + body: `{"type":"service_unavailable","code":"temporarily_unavailable","error":"capacity exhausted"}`, + wantType: "service_unavailable", + wantCode: "temporarily_unavailable", + wantMsg: "capacity exhausted", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseOpsErrorResponse([]byte(tt.body)) + require.Equal(t, tt.wantType, normalizeOpsErrorType(parsed.ErrorType, parsed.Code)) + require.Equal(t, tt.wantCode, parsed.Code) + require.Equal(t, tt.wantMsg, parsed.Message) + }) + } +} + +func TestApplyOpsUpstreamFieldsUsesLastNonNilAttempt(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.SetOpsUpstreamError(c, http.StatusUnauthorized, "stale context", "stale detail") + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{ + {UpstreamStatusCode: http.StatusTooManyRequests, Message: "first attempt", Detail: "first detail"}, + nil, + {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "final attempt", Detail: "final detail"}, + nil, + }) + entry := &service.OpsInsertErrorLogInput{} + + applyOpsUpstreamFieldsFromContext(c, entry) + + require.NotNil(t, entry.UpstreamStatusCode) + require.Equal(t, http.StatusServiceUnavailable, *entry.UpstreamStatusCode) + require.NotNil(t, entry.UpstreamErrorMessage) + require.Equal(t, "final attempt", *entry.UpstreamErrorMessage) + require.NotNil(t, entry.UpstreamErrorDetail) + require.Equal(t, "final detail", *entry.UpstreamErrorDetail) + require.Len(t, entry.UpstreamErrors, 4) +} + +func TestApplyOpsUpstreamFieldsFinalStatuslessAttemptClearsStaleContext(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.SetOpsUpstreamError(c, http.StatusBadGateway, "stale response", "stale body") + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{ + {UpstreamStatusCode: http.StatusBadGateway, Message: "first response"}, + {Kind: "request_error", Message: "final transport failure", Detail: "connection reset"}, + }) + entry := &service.OpsInsertErrorLogInput{} + + applyOpsUpstreamFieldsFromContext(c, entry) + + require.Nil(t, entry.UpstreamStatusCode) + require.NotNil(t, entry.UpstreamErrorMessage) + require.Equal(t, "final transport failure", *entry.UpstreamErrorMessage) + require.NotNil(t, entry.UpstreamErrorDetail) + require.Equal(t, "connection reset", *entry.UpstreamErrorDetail) +} + +func TestOpsCaptureWriter_ProtocolLevelTerminalFrameDetection(t *testing.T) { + state := &opsCaptureWriterState{limit: opsCaptureWriterLimit} + chunks := []string{ + "event : response.failed\r\n", + "data: { \"response\" : { \"error\" : { \"message\" : \"busy\", \"code\" : \"service_unavailable\" } },", + " \"type\" : \"response.failed\" }\r\n\r\n", + } + for _, chunk := range chunks { + state.captureResponseChunk([]byte(chunk), http.StatusOK) + } + + parsed := parseOpsErrorResponse(state.buf.Bytes()) + require.True(t, state.sseCapturing) + require.True(t, parsed.StreamFailure) + require.Equal(t, "service_unavailable_error", parsed.ErrorType) + require.Equal(t, "service_unavailable", parsed.Code) + require.Equal(t, "busy", parsed.Message) + require.Equal(t, http.StatusServiceUnavailable, inferStreamFailureStatus(nil, parsed)) +} + +func TestParseOpsSSEFailure_TopLevelErrorsAndUnknownStatus(t *testing.T) { + tests := []struct { + name string + body string + wantType string + wantStatus int + }{ + { + name: "top-level permission", + body: "event: error\ndata: {\"message\":\"denied\",\"code\":\"permission_denied\",\"type\":\"error\"}\n\n", + wantType: "permission_error", + wantStatus: http.StatusForbidden, + }, + { + name: "top-level unavailable", + body: "data: {\"message\":\"busy\",\"type\":\"error\",\"code\":\"service_unavailable\"}\n\n", + wantType: "service_unavailable_error", + wantStatus: http.StatusServiceUnavailable, + }, + { + name: "unknown terminal", + body: "event: response.failed\ndata: {\"type\":\"response.failed\",\"error\":{\"code\":\"new_provider_code\",\"message\":\"failed\"}}\n\n", + wantType: "upstream_error", + wantStatus: http.StatusBadGateway, + }, + { + name: "explicit terminal status", + body: "event: error\ndata: {\"type\":\"error\",\"status_code\":429,\"code\":\"new_rate_code\",\"message\":\"slow down\"}\n\n", + wantType: "api_error", + wantStatus: http.StatusTooManyRequests, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseOpsErrorResponse([]byte(tt.body)) + require.True(t, parsed.StreamFailure) + require.Equal(t, tt.wantType, parsed.ErrorType) + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.SetOpsUpstreamError(c, http.StatusUnauthorized, "old attempt", "") + require.Equal(t, tt.wantStatus, inferStreamFailureStatus(c, parsed), "terminal status must not inherit an earlier attempt") + }) + } +} + +func TestOpsCaptureWriter_OversizedNonTerminalFrameRemainsBounded(t *testing.T) { + state := &opsCaptureWriterState{limit: opsCaptureWriterLimit} + state.captureResponseChunk([]byte("data: "+strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2)+"\n\n"), http.StatusOK) + require.Empty(t, state.buf.Bytes()) + require.LessOrEqual(t, cap(state.probe), opsTerminalSSEFrameProbeLimit) + + state.captureResponseChunk([]byte("event: error\ndata: {\"type\":\"error\",\"code\":\"permission_denied\",\"message\":\"denied\"}\n\n"), http.StatusOK) + require.True(t, state.sseCapturing) + require.NotEmpty(t, state.buf.Bytes()) +} + +func TestOpsCaptureWriter_TerminalMetadataSurvivesBodyCaptureTruncation(t *testing.T) { + state := &opsCaptureWriterState{limit: opsCaptureWriterLimit} + frame := "event: response.failed\ndata: {\"padding\":\"" + strings.Repeat("x", opsCaptureWriterLimit) + "\",\"type\":\"response.failed\",\"error\":{\"code\":\"service_unavailable\",\"message\":\"busy\"}}\n\n" + state.captureResponseChunk([]byte(frame), http.StatusOK) + state.finalizeResponseCapture() + + require.Len(t, state.buf.Bytes(), opsCaptureWriterLimit) + require.True(t, parseOpsErrorResponse(state.buf.Bytes()).StreamFailure, "the bounded parser must fail closed from the terminal event line") + require.True(t, state.terminalFound) + require.Equal(t, "service_unavailable_error", state.terminalError.ErrorType) + require.Equal(t, "busy", state.terminalError.Message) +} + +func TestOpsErrorLoggerMiddleware_LargeTerminalFrameUsesEventFallback(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Status(http.StatusOK) + _, _ = c.Writer.WriteString("event: response.failed\n") + _, _ = c.Writer.WriteString("data: {\"authorization\":\"Bearer must-not-persist\",\"padding\":\"" + strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2) + "\"}") + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusBadGateway, job.entry.StatusCode) + require.Equal(t, "upstream_error", job.entry.ErrorType) + require.Equal(t, "upstream stream failed", job.entry.ErrorMessage) + require.NotContains(t, job.entry.ErrorMessage, "must-not-persist") + require.NotContains(t, job.entry.ErrorBody, "must-not-persist") + require.Contains(t, job.entry.ErrorBody, `"payload_truncated":true`) +} + +func TestOpsErrorLoggerMiddleware_DetectsTerminalDataAtEOFWithoutBlankLine(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Status(http.StatusOK) + _, _ = c.Writer.WriteString(`data: {"message":"denied","code":"permission_denied","type":"error"}`) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusForbidden, job.entry.StatusCode) + require.Equal(t, "permission_error", job.entry.ErrorType) + require.Equal(t, "denied", job.entry.ErrorMessage) +} + +func TestOpsCaptureWriter_DetectsCROnlySSEFrame(t *testing.T) { + state := &opsCaptureWriterState{limit: opsCaptureWriterLimit} + state.captureResponseChunk([]byte("data: {\"type\":\"error\",\"code\":\"service_unavailable\",\"message\":\"busy\"}\r\r"), http.StatusOK) + + require.True(t, state.sseCapturing) + parsed := parseOpsErrorResponse(state.buf.Bytes()) + require.True(t, parsed.StreamFailure) + require.Equal(t, "service_unavailable_error", parsed.ErrorType) +} + +func TestSanitizeOpsSSEDataForPersistence_RedactsJSONFields(t *testing.T) { + body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer secret\",\ndata: \"nested\":{\"api_key\":\"sk-secret\"}}\n\n") + sanitized := sanitizeOpsSSEDataForPersistence(body) + require.NotContains(t, sanitized, "Bearer secret") + require.NotContains(t, sanitized, "sk-secret") + require.Contains(t, sanitized, `"authorization":"[REDACTED]"`) + require.Contains(t, sanitized, `"api_key":"[REDACTED]"`) +} + +func TestSanitizeOpsSSEDataForPersistence_DropsTruncatedJSONFragment(t *testing.T) { + body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer leaked") + sanitized := sanitizeOpsSSEDataForPersistence(body) + require.NotContains(t, sanitized, "Bearer leaked") + require.Contains(t, sanitized, `data: {"payload_truncated":true}`) +} + +func BenchmarkOpsCaptureWriterSuccessfulSSEFrames(b *testing.B) { + frame := []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n") + state := &opsCaptureWriterState{limit: opsCaptureWriterLimit} + b.ReportAllocs() + for i := 0; i < b.N; i++ { + state.captureResponseChunk(frame, http.StatusOK) + } + if state.buf.Len() != 0 { + b.Fatal("successful frames must not be captured") + } +} + func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/handler/ops_ingress_reject_capture_test.go b/backend/internal/handler/ops_ingress_reject_capture_test.go index 6848bcfd90..da93fedd77 100644 --- a/backend/internal/handler/ops_ingress_reject_capture_test.go +++ b/backend/internal/handler/ops_ingress_reject_capture_test.go @@ -15,11 +15,11 @@ func TestOpsCaptureWriterDoesNotCopyIngressRejectBody(t *testing.T) { context, _ := gin.CreateTestContext(httptest.NewRecorder()) writer := acquireOpsCaptureWriter(context.Writer) defer releaseOpsCaptureWriter(writer) - writer.ctx = context + writer.setContext(context) context.Writer = writer middleware2.MarkIngressRejected(context, middleware2.IngressRejectInvalidAPIKey) context.Status(http.StatusUnauthorized) _, err := context.Writer.WriteString(`{"code":"INVALID_API_KEY","message":"Invalid API key"}`) require.NoError(t, err) - require.Zero(t, writer.buf.Len()) + require.Empty(t, writer.capturedBytes()) } diff --git a/backend/internal/handler/request_body_read_log.go b/backend/internal/handler/request_body_read_log.go new file mode 100644 index 0000000000..5c6d73d934 --- /dev/null +++ b/backend/internal/handler/request_body_read_log.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strings" + "syscall" + + "go.uber.org/zap" +) + +// logRequestBodyReadFailure records a bounded, payload-free reason for a body +// read failure. Clients continue to receive the stable generic error message; +// operators get enough information to distinguish compression failures from a +// disconnected/truncated upload without logging request content. +func logRequestBodyReadFailure(reqLog *zap.Logger, req *http.Request, err error) { + if reqLog == nil || err == nil { + return + } + + contentLength := int64(-1) + contentEncoding := "identity" + if req != nil { + contentLength = req.ContentLength + contentEncoding = requestContentEncodingCategory(req.Header.Get("Content-Encoding")) + } + + reqLog.Warn("read request body failed", + zap.String("error_kind", requestBodyReadErrorKind(err)), + zap.String("content_encoding", contentEncoding), + zap.Int64("content_length", contentLength), + ) +} + +func requestContentEncodingCategory(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "identity": + return "identity" + case "gzip", "x-gzip": + return "gzip" + case "zstd": + return "zstd" + case "deflate": + return "deflate" + default: + return "other" + } +} + +func requestBodyReadErrorKind(err error) string { + if err == nil { + return "none" + } + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + return "max_bytes" + } + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "decode content-encoding") { + if strings.Contains(lower, "unsupported content-encoding") { + return "unsupported_content_encoding" + } + return "decode_content_encoding" + } + if errors.Is(err, context.Canceled) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) { + return "client_disconnect" + } + if errors.Is(err, io.ErrUnexpectedEOF) { + return "truncated_body" + } + var netErr net.Error + if errors.As(err, &netErr) { + return "transport" + } + return "io_read" +} diff --git a/backend/internal/handler/request_body_read_log_test.go b/backend/internal/handler/request_body_read_log_test.go new file mode 100644 index 0000000000..8a8d21f100 --- /dev/null +++ b/backend/internal/handler/request_body_read_log_test.go @@ -0,0 +1,40 @@ +//go:build unit + +package handler + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLogRequestBodyReadFailureClassifiesWithoutPayload(t *testing.T) { + log, logs := newObservedLogger(t) + req, err := http.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader("secret-payload-marker")) + require.NoError(t, err) + req.Header.Set("Content-Encoding", "gzip") + req.ContentLength = 21 + + logRequestBodyReadFailure(log, req, errors.New(`decode Content-Encoding "gzip": unexpected EOF secret-payload-marker`)) + + entries := logs.All() + require.Len(t, entries, 1) + require.Equal(t, "read request body failed", entries[0].Message) + fields := entries[0].ContextMap() + require.Equal(t, "decode_content_encoding", fields["error_kind"]) + require.Equal(t, "gzip", fields["content_encoding"]) + require.EqualValues(t, 21, fields["content_length"]) + require.NotContains(t, entries[0].Message+fmt.Sprint(fields), "secret-payload-marker") +} + +func TestRequestBodyReadErrorKind(t *testing.T) { + require.Equal(t, "unsupported_content_encoding", requestBodyReadErrorKind(errors.New(`decode Content-Encoding "br": unsupported Content-Encoding`))) + require.Equal(t, "truncated_body", requestBodyReadErrorKind(io.ErrUnexpectedEOF)) + require.Equal(t, "max_bytes", requestBodyReadErrorKind(&http.MaxBytesError{Limit: 10})) + require.Equal(t, "other", requestContentEncodingCategory("private-payload-marker")) +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index a415d67e34..d314a4f94b 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -107,11 +107,13 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - ModelPlazaEnabled: settings.ModelPlazaEnabled, - ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + ModelPlazaEnabled: settings.ModelPlazaEnabled, + ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + PluginManagementEnabled: settings.PluginManagementEnabled, AffiliateEnabled: settings.AffiliateEnabled, diff --git a/backend/internal/handler/user_msg_queue_helper.go b/backend/internal/handler/user_msg_queue_helper.go index 50449b1399..839b14cbb6 100644 --- a/backend/internal/handler/user_msg_queue_helper.go +++ b/backend/internal/handler/user_msg_queue_helper.go @@ -121,9 +121,11 @@ func (h *UserMsgQueueHelper) waitForLockWithPing( c.Header("X-Accel-Buffering", "no") *streamStarted = true } - if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil { + written, err := fmt.Fprint(c.Writer, string(h.pingFormat)) + if err != nil { return nil, err } + recordGatewayStreamHeartbeat(c, written) flusher.Flush() case <-timer.C: @@ -226,9 +228,11 @@ func (h *UserMsgQueueHelper) ThrottleWithPing( c.Header("X-Accel-Buffering", "no") *streamStarted = true } - if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil { + written, err := fmt.Fprint(c.Writer, string(h.pingFormat)) + if err != nil { return err } + recordGatewayStreamHeartbeat(c, written) flusher.Flush() case <-timer.C: return nil diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 36fdb85280..0e75dc9258 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -23,6 +23,7 @@ func ProvideAdminHandlers( geminiOAuthHandler *admin.GeminiOAuthHandler, antigravityOAuthHandler *admin.AntigravityOAuthHandler, grokOAuthHandler *admin.GrokOAuthHandler, + cnProviderHandler *admin.CNProviderHandler, proxyHandler *admin.ProxyHandler, redeemHandler *admin.RedeemHandler, promoHandler *admin.PromoHandler, @@ -34,6 +35,7 @@ func ProvideAdminHandlers( userAttributeHandler *admin.UserAttributeHandler, errorPassthroughHandler *admin.ErrorPassthroughHandler, tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler, + pluginHandler *admin.PluginHandler, apiKeyHandler *admin.AdminAPIKeyHandler, scheduledTestHandler *admin.ScheduledTestHandler, channelHandler *admin.ChannelHandler, @@ -63,6 +65,7 @@ func ProvideAdminHandlers( GeminiOAuth: geminiOAuthHandler, AntigravityOAuth: antigravityOAuthHandler, GrokOAuth: grokOAuthHandler, + CNProvider: cnProviderHandler, Proxy: proxyHandler, Redeem: redeemHandler, Promo: promoHandler, @@ -74,6 +77,7 @@ func ProvideAdminHandlers( UserAttribute: userAttributeHandler, ErrorPassthrough: errorPassthroughHandler, TLSFingerprintProfile: tlsFingerprintProfileHandler, + Plugin: pluginHandler, APIKey: apiKeyHandler, ScheduledTest: scheduledTestHandler, Channel: channelHandler, @@ -115,6 +119,7 @@ func ProvideGatewayHandler( func ProvideOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, + pluginManager *service.PluginManager, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, @@ -126,6 +131,7 @@ func ProvideOpenAIGatewayHandler( cfg *config.Config, coordinator *securityaudit.Coordinator, ) *OpenAIGatewayHandler { + gatewayService.SetPluginManager(pluginManager) h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) h.securityAuditCoordinator = coordinator @@ -190,6 +196,7 @@ func ProvideHandlers( batchImageHandler *BatchImageHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, + _ *service.OpenAIQuotaAutoResetService, ) *Handlers { return &Handlers{ Auth: authHandler, @@ -253,6 +260,7 @@ var ProviderSet = wire.NewSet( admin.NewGeminiOAuthHandler, admin.NewAntigravityOAuthHandler, admin.NewGrokOAuthHandler, + admin.NewCNProviderHandler, admin.NewProxyHandler, admin.NewRedeemHandler, admin.NewPromoHandler, @@ -264,6 +272,7 @@ var ProviderSet = wire.NewSet( admin.NewUserAttributeHandler, admin.NewErrorPassthroughHandler, admin.NewTLSFingerprintProfileHandler, + admin.NewPluginHandler, admin.NewAdminAPIKeyHandler, admin.NewScheduledTestHandler, admin.NewChannelHandler, diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index aa202069bd..bb3a315771 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -1,7 +1,11 @@ // Package model 定义服务层使用的数据模型。 package model -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) // ErrorPassthroughRule 全局错误透传规则 // 用于控制上游错误如何返回给客户端 @@ -32,16 +36,28 @@ const MatchModeAll = "all" // 支持的平台常量 const ( - PlatformAnthropic = "anthropic" - PlatformOpenAI = "openai" - PlatformGemini = "gemini" - PlatformAntigravity = "antigravity" - PlatformGrok = "grok" + PlatformAnthropic = domain.PlatformAnthropic + PlatformOpenAI = domain.PlatformOpenAI + PlatformGemini = domain.PlatformGemini + PlatformAntigravity = domain.PlatformAntigravity + PlatformGrok = domain.PlatformGrok + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek ) // AllPlatforms 返回所有支持的平台列表 func AllPlatforms() []string { - return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok} + return []string{ + PlatformAnthropic, + PlatformOpenAI, + PlatformGemini, + PlatformAntigravity, + PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, + } } // Validate 验证规则配置的有效性 diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go new file mode 100644 index 0000000000..fc9ff5b385 --- /dev/null +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -0,0 +1,20 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { + require.ElementsMatch(t, []string{ + "anthropic", + "openai", + "gemini", + "antigravity", + "grok", + "kimi", + "zhipu", + "deepseek", + }, AllPlatforms()) +} diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index 785c8af3c4..a3ea6709f7 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -255,8 +255,6 @@ const ( proxyTLSHandshakeTimeout = 5 * time.Second // clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body) clientTimeout = 10 * time.Second - // fetchAvailableModelsBodyLimit limits model-list responses to avoid unbounded memory use. - fetchAvailableModelsBodyLimit int64 = 8 << 20 ) func NewClient(proxyURL string) (*Client, error) { @@ -656,10 +654,13 @@ type FetchAvailableModelsResponse struct { // FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON // 支持 URL fallback:sandbox → daily → prod -func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) { +func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) { if c == nil || c.httpClient == nil { return nil, nil, errors.New("antigravity client is not configured") } + if bodyLimit <= 0 { + return nil, nil, errors.New("fetchAvailableModels body limit must be positive") + } reqBody := FetchAvailableModelsRequest{Project: projectID} bodyBytes, err := json.Marshal(reqBody) @@ -693,13 +694,13 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI return nil, nil, lastErr } - respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, fetchAvailableModelsBodyLimit+1)) + respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) _ = resp.Body.Close() // 立即关闭,避免循环内 defer 导致的资源泄漏 if err != nil { return nil, nil, fmt.Errorf("读取响应失败: %w", err) } - if int64(len(respBodyBytes)) > fetchAvailableModelsBodyLimit { - return nil, nil, fmt.Errorf("响应超过 %d 字节", fetchAvailableModelsBodyLimit) + if int64(len(respBodyBytes)) > bodyLimit { + return nil, nil, fmt.Errorf("响应超过 %d 字节", bodyLimit) } // 检查是否需要 URL 降级 diff --git a/backend/internal/pkg/antigravity/client_test.go b/backend/internal/pkg/antigravity/client_test.go index b6c2e6a502..7a525879fa 100644 --- a/backend/internal/pkg/antigravity/client_test.go +++ b/backend/internal/pkg/antigravity/client_test.go @@ -15,6 +15,8 @@ import ( "time" ) +const defaultFetchAvailableModelsBodyLimit int64 = 8 << 20 + // --------------------------------------------------------------------------- // NewAPIRequestWithURL // --------------------------------------------------------------------------- @@ -1517,7 +1519,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc") + resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 失败: %v", err) } @@ -1562,6 +1564,34 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { } } +func TestClient_FetchAvailableModels_RejectsNonPositiveBodyLimit(t *testing.T) { + client := mustNewClient(t, "") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 0) + if err == nil || !strings.Contains(err.Error(), "body limit must be positive") { + t.Fatalf("非正读取上限应返回调用错误: %v", err) + } +} + +func TestClient_FetchAvailableModels_UsesConfiguredBodyLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`)) + })) + defer server.Close() + + withMockBaseURLs(t, []string{server.URL}) + + client := mustNewClient(t, "") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 8) + if err == nil { + t.Fatal("响应超过配置上限时应返回错误") + } + if !strings.Contains(err.Error(), "响应超过 8 字节") { + t.Fatalf("错误应包含配置上限: %v", err) + } +} + func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -1572,7 +1602,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("服务器返回 403 时应返回错误") } @@ -1592,7 +1622,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("无效 JSON 响应应返回错误") } @@ -1622,7 +1652,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err) } @@ -1650,7 +1680,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("所有 URL 都失败时应返回错误") } @@ -1669,7 +1699,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := client.FetchAvailableModels(ctx, "token", "proj") + _, _, err := client.FetchAvailableModels(ctx, "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("context 取消时应返回错误") } @@ -1686,7 +1716,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 失败: %v", err) } @@ -1748,7 +1778,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err) } diff --git a/backend/internal/pkg/antigravity/gemini_types.go b/backend/internal/pkg/antigravity/gemini_types.go index 033dccbd5d..4fd7882adc 100644 --- a/backend/internal/pkg/antigravity/gemini_types.go +++ b/backend/internal/pkg/antigravity/gemini_types.go @@ -112,7 +112,8 @@ type GeminiImageSearch struct { // GeminiToolConfig Gemini 工具配置 type GeminiToolConfig struct { - FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"` + FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"` + IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"` } // GeminiFunctionCallingConfig 函数调用配置 diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 4ffb0cbad9..26b307bbf7 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -54,7 +54,7 @@ const ( // Antigravity API 端点 antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com" - antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com" + antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com" ) var userAgentVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) diff --git a/backend/internal/pkg/antigravity/oauth_test.go b/backend/internal/pkg/antigravity/oauth_test.go index 69d9ddc4f8..7849492c7c 100644 --- a/backend/internal/pkg/antigravity/oauth_test.go +++ b/backend/internal/pkg/antigravity/oauth_test.go @@ -94,6 +94,9 @@ func TestForwardBaseURLs_Daily优先(t *testing.T) { if len(urls) == 0 { t.Fatal("ForwardBaseURLs 返回空列表") } + if antigravityDailyBaseURL != "https://daily-cloudcode-pa.googleapis.com" { + t.Fatalf("daily URL 未与官方客户端对齐: got %s", antigravityDailyBaseURL) + } // daily URL 应排在第一位 if urls[0] != antigravityDailyBaseURL { diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 7d0a707959..9da3cc33a2 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -156,6 +156,13 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map Mode: "VALIDATED", }, } + // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 + // includeServerSideToolInvocations,否则返回 400(issue #5709)。 + // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 + if hasMixedToolInvocations(tools) { + enabled := true + innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled + } } if systemInstruction != nil { @@ -703,6 +710,21 @@ func isWebSearchTool(tool ClaudeTool) bool { } } +// hasMixedToolInvocations 判断构建后的工具声明是否同时包含函数声明与内置工具 +// (googleSearch)。仅在两者并存时需要开启 includeServerSideToolInvocations。 +func hasMixedToolInvocations(declarations []GeminiToolDeclaration) bool { + hasFunc, hasBuiltin := false, false + for _, d := range declarations { + if len(d.FunctionDeclarations) > 0 { + hasFunc = true + } + if d.GoogleSearch != nil { + hasBuiltin = true + } + } + return hasFunc && hasBuiltin +} + // buildTools 构建 tools func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { if len(tools) == 0 { diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index ea95cc7ea6..05764ef7ff 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -565,3 +565,59 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name) require.NotNil(t, req.Request.Tools[1].GoogleSearch) } + +func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { + functionTool := ClaudeTool{ + Name: "get_weather", + Description: "Get weather information", + InputSchema: map[string]any{"type": "object"}, + } + webSearchTool := ClaudeTool{ + Type: "web_search_20250305", + Name: "web_search", + } + + transform := func(t *testing.T, tools []ClaudeTool) (V1InternalRequest, string) { + t.Helper() + body, err := TransformClaudeToGeminiWithOptions(&ClaudeRequest{ + Model: "claude-3-5-sonnet-latest", + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), + }, + }, + Tools: tools, + }, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + return req, string(body) + } + + t.Run("mixed builtin and function tools enable server-side tool invocations", func(t *testing.T) { + req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool}) + + require.NotNil(t, req.Request.ToolConfig) + require.NotNil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) + require.True(t, *req.Request.ToolConfig.IncludeServerSideToolInvocations) + require.Contains(t, raw, `"includeServerSideToolInvocations":true`) + }) + + t.Run("function tools only leave the flag unset", func(t *testing.T) { + req, raw := transform(t, []ClaudeTool{functionTool}) + + require.NotNil(t, req.Request.ToolConfig) + require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) + require.NotContains(t, raw, "includeServerSideToolInvocations") + }) + + t.Run("web search only leaves the flag unset", func(t *testing.T) { + req, raw := transform(t, []ClaudeTool{webSearchTool}) + + require.NotNil(t, req.Request.ToolConfig) + require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) + require.NotContains(t, raw, "includeServerSideToolInvocations") + }) +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index eb979f70e0..8ebd5092fa 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -677,7 +677,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) { direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") // Double-conversion bridge - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") // Compare key fields @@ -991,7 +991,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_CacheWriteTokens(t *te direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens) @@ -1061,7 +1061,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testin direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason)) diff --git a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go index 87c4f9e0ba..69d633f0a9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go @@ -44,7 +44,7 @@ func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) { func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) { var response ChatCompletionsResponse readIssue5302Fixture(t, "nonstream_reasoning.json", &response) - nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil) + nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, nil, false, nil) require.Len(t, nonStream.Output, 2) require.Equal(t, "reasoning", nonStream.Output[0].Type) require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 70dee035d8..fd84af7824 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -17,15 +17,37 @@ const ( type toolOutputMediaByCallID map[string][]ChatContentPart +// ResponsesToChatOptions carries optional hooks for +// ResponsesToChatCompletionsRequestWithOptions. All fields are optional; a nil +// *ResponsesToChatOptions behaves exactly like ResponsesToChatCompletionsRequest. +type ResponsesToChatOptions struct { + // ReasoningContentByID looks up the cached reasoning text for a reasoning + // item id. Codex histories may carry reasoning items with no plaintext + // summary (empty summary + opaque encrypted_content, e.g. after remote + // compaction); DeepSeek's thinking mode rejects such histories with 400 + // "The `reasoning_content` in the thinking mode must be passed back to the + // API". The gateway caches the reasoning text it streamed under the item + // id, so the lookup restores the reasoning_content the client can no + // longer provide. Return "" on a miss. A nil lookup keeps the original + // behavior. + ReasoningContentByID func(itemID string) string +} + // ResponsesToChatCompletionsRequest converts a Responses API request into a // Chat Completions request for upstreams that only implement // /v1/chat/completions. func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsRequest, error) { + return ResponsesToChatCompletionsRequestWithOptions(req, nil) +} + +// ResponsesToChatCompletionsRequestWithOptions is ResponsesToChatCompletionsRequest +// with optional hooks (see ResponsesToChatOptions). +func ResponsesToChatCompletionsRequestWithOptions(req *ResponsesRequest, opts *ResponsesToChatOptions) (*ChatCompletionsRequest, error) { if req == nil { return nil, fmt.Errorf("responses request is nil") } - messages, err := responsesInputToChatMessages(req.Instructions, req.Input) + messages, err := responsesInputToChatMessagesWithOptions(req.Instructions, req.Input, opts) if err != nil { return nil, err } @@ -137,6 +159,20 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool { return out } +// FunctionToolNames collects explicitly declared top-level function tools. +func FunctionToolNames(tools []ResponsesTool) map[string]bool { + var out map[string]bool + for _, tool := range tools { + if tool.Type == "function" && tool.Name != "" { + if out == nil { + out = make(map[string]bool) + } + out[tool.Name] = true + } + } + return out +} + // NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。 type NamespacedToolName struct { Namespace string @@ -175,6 +211,43 @@ func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName { return out } +// customToolCallName restores both the exact downgraded custom-tool name and +// namespace-prefixed aliases that chat models sometimes infer from neighboring +// flattened namespace tools (for example functions__exec beside +// functions__wait). A real declared namespace child always owns its flattened +// name, and ambiguous aliases are left as ordinary function calls. +func customToolCallName(name string, customTools, functionTools map[string]bool, namespaceTools map[string]NamespacedToolName) (string, bool) { + if functionTools[name] { + return "", false + } + if customTools[name] { + return name, true + } + if _, ok := namespaceTools[name]; ok { + return "", false + } + match := "" + for customName := range customTools { + for _, namespaceTool := range namespaceTools { + if flattenNamespaceToolName(namespaceTool.Namespace, customName) != name { + continue + } + if match != "" && match != customName { + return "", false + } + match = customName + } + } + return match, match != "" +} + +func customNameForStreamTool(state *ChatCompletionsToResponsesStreamState, name string) string { + if customName, ok := customToolCallName(name, state.CustomTools, state.FunctionTools, state.NamespaceTools); ok { + return customName + } + return name +} + // HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥 // 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型 // 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配 @@ -202,6 +275,12 @@ func HasToolSearchTool(tools []ResponsesTool) bool { // scattered across per-item cases, and makes unknown future codex item types // fail safe instead of leaking into the upstream request. func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) ([]ChatMessage, error) { + return responsesInputToChatMessagesWithOptions(instructions, inputRaw, nil) +} + +// responsesInputToChatMessagesWithOptions is responsesInputToChatMessages with +// optional hooks (see ResponsesToChatOptions). +func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, error) { var messages []ChatMessage if strings.TrimSpace(instructions) != "" { content, _ := json.Marshal(instructions) @@ -226,7 +305,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) return nil, fmt.Errorf("parse responses input: %w", err) } - built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems) + built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems, opts) if err != nil { return nil, err } @@ -235,7 +314,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) // buildChatMessagesFromItems walks the Responses input items and appends the // corresponding Chat messages. -func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, toolOutputMediaByCallID, error) { +func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, toolOutputMediaByCallID, error) { // pendingReasoning holds the reasoning text from a reasoning item until the // assistant message it belongs to is emitted. DeepSeek's thinking mode // requires the reasoning_content that produced a tool call to be passed back @@ -243,7 +322,23 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa // across an assistant message (so a following tool call in the same turn // still receives it); any other role ends the thinking span. var pendingReasoning string + // lastTurnReasoning is the most recent reasoning text of the current turn, + // surviving tool outputs. DeepSeek emits reasoning only once per turn, so + // chained tool calls (reasoning → call A → output A → call B) leave call B's + // assistant message without reasoning_content and DeepSeek 400s the history; + // replaying the turn's reasoning on B's message satisfies the contract. Only + // a user-side item ends the turn and clears it. + var lastTurnReasoning string mediaByCallID := make(toolOutputMediaByCallID) + invalidFunctionCallIDs := make(map[string]struct{}) + invalidEmptyFunctionCallOutputs := 0 + + reasoningForAssistant := func() string { + if pendingReasoning != "" { + return pendingReasoning + } + return lastTurnReasoning + } for _, raw := range rawItems { raw = bytesTrimSpace(raw) @@ -258,6 +353,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa content, _ := json.Marshal(text) messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue } return nil, nil, fmt.Errorf("parse responses input item: %w", err) @@ -269,6 +365,18 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa case "reasoning": if txt := extractResponsesReasoningText(item); txt != "" { pendingReasoning = txt + } else if opts != nil && opts.ReasoningContentByID != nil { + // No plaintext summary (encrypted-only reasoning, e.g. after codex + // remote compaction): fall back to the gateway-side cache keyed + // by the reasoning item id, which always round-trips in history. + if id := rawString(item["id"]); id != "" { + if cached := opts.ReasoningContentByID(id); cached != "" { + pendingReasoning = cached + } + } + } + if pendingReasoning != "" { + lastTurnReasoning = pendingReasoning } continue case "function_call": @@ -276,6 +384,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa if strings.TrimSpace(arguments) == "" { arguments = "{}" } + callID := rawString(item["call_id"]) + if !json.Valid([]byte(arguments)) { + // A previous streamed turn can leave a truncated function_call in + // Codex history (for example after an upstream SSE parse failure or + // an output-limit interruption). Do not forward that item to a + // Chat Completions provider, which rejects the entire request. Its + // matching output is skipped below as well, allowing the next user + // turn to self-heal instead of repeatedly replaying the poison. + if callID != "" { + invalidFunctionCallIDs[callID] = struct{}{} + } else { + invalidEmptyFunctionCallOutputs++ + } + pendingReasoning = "" + continue + } name := rawString(item["name"]) // namespace 子工具的历史调用带 namespace 字段,需与请求方向的摊平 // 命名(namespaceChildrenToChatTools)保持一致。 @@ -283,14 +407,14 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa name = flattenNamespaceToolName(ns, name) } toolCall := ChatToolCall{ - ID: rawString(item["call_id"]), + ID: callID, Type: "function", Function: ChatFunctionCall{ Name: name, Arguments: arguments, }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "tool_search_call": @@ -311,7 +435,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: arguments, }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "custom_tool_call": @@ -327,12 +451,21 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: string(arguments), }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "function_call_output", "custom_tool_call_output", "tool_search_output": outputRaw := bytesTrimSpace(item["output"]) callID := rawString(item["call_id"]) + if callID == "" && invalidEmptyFunctionCallOutputs > 0 { + invalidEmptyFunctionCallOutputs-- + pendingReasoning = "" + continue + } + if _, skipped := invalidFunctionCallIDs[callID]; skipped { + pendingReasoning = "" + continue + } delete(mediaByCallID, callID) outputText, media, rewritten := extractToolOutputMedia(outputRaw) @@ -359,6 +492,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa content, _ := json.Marshal(rawString(item["text"])) messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue case "input_image": content, err := chatContentFromSingleResponsesPart(itemType, item) @@ -367,6 +501,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa } messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue } @@ -391,11 +526,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa if err != nil { return nil, nil, err } - messages = append(messages, ChatMessage{Role: role, Content: chatContent}) - // Reasoning only survives across an assistant text message. - if role != "assistant" { + msg := ChatMessage{Role: role, Content: chatContent} + // DeepSeek thinking mode requires the reasoning_content from a prior + // reasoning-only / plain-text assistant turn to be passed back on its + // assistant message; dropping it yields 400 "The `reasoning_content` in + // the thinking mode must be passed back to the API" on the next turn. + // A following function_call in the same turn still receives it because + // appendAssistantToolCall merges into this message and only fills + // ReasoningContent when it is still empty. + if role == "assistant" { + msg.ReasoningContent = reasoningForAssistant() pendingReasoning = "" + } else { + pendingReasoning = "" + lastTurnReasoning = "" } + messages = append(messages, msg) } return messages, mediaByCallID, nil @@ -687,6 +833,26 @@ func extractResponsesReasoningText(item map[string]json.RawMessage) string { return strings.Join(parts, "\n") } +// ExtractResponsesReasoningItem parses a raw Responses input item and, when it +// is a reasoning item, returns its id and extractable plaintext (summary +// preferred, content fallback). ok is false for non-reasoning items. It exists +// for the gateway-side reasoning cache: items with plaintext get (re)cached so +// later encrypted-only replicas of the same item id can be restored. +func ExtractResponsesReasoningItem(raw json.RawMessage) (id string, text string, ok bool) { + raw = bytesTrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return "", "", false + } + var item map[string]json.RawMessage + if err := json.Unmarshal(raw, &item); err != nil { + return "", "", false + } + if rawString(item["type"]) != "reasoning" { + return "", "", false + } + return rawString(item["id"]), extractResponsesReasoningText(item), true +} + func chatCompletionsBridgeRole(role string) string { trimmed := strings.TrimSpace(role) if trimmed == "" { @@ -803,6 +969,9 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { topLevel := make(map[string]bool) for _, tool := range tools { if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" { + if topLevel[tool.Name] { + return nil, fmt.Errorf("duplicate top-level executable tool name %q; this upstream cannot disambiguate duplicate names, rename one of the tools", tool.Name) + } topLevel[tool.Name] = true } } @@ -1033,7 +1202,7 @@ func extractCustomToolCallInput(arguments string) string { // toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 // 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名 // 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。 -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -1043,10 +1212,11 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str } out := &ResponsesResponse{ - ID: id, - Object: "response", - Model: model, - Status: "completed", + ID: id, + Object: "response", + Model: model, + Status: "completed", + ServiceTier: chatServiceTier(resp), } if resp == nil { out.Output = []ResponsesOutput{emptyResponsesMessageOutput()} @@ -1058,7 +1228,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, functionTools, toolSearch, namespaceTools) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -1073,7 +1243,14 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { +func chatServiceTier(resp *ChatCompletionsResponse) string { + if resp == nil { + return "" + } + return resp.ServiceTier +} + +func chatMessageToResponsesOutput(message ChatMessage, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput reasoning := message.reasoningText() if reasoning != "" { @@ -1109,12 +1286,12 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo if strings.TrimSpace(arguments) == "" { arguments = "{}" } - if customTools[toolCall.Function.Name] { + if customName, ok := customToolCallName(toolCall.Function.Name, customTools, functionTools, namespaceTools); ok { outputs = append(outputs, ResponsesOutput{ Type: "custom_tool_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customName, Input: extractCustomToolCallInput(arguments), Status: "completed", }) @@ -1130,6 +1307,13 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo }) continue } + // Ordinary Responses function_call arguments must contain valid JSON. + // Do not mark a truncated non-streaming Chat tool call as completed; + // Codex would persist it and poison the next request in the same way as + // the streaming variant guarded by ValidateToolCallArguments. + if !json.Valid([]byte(arguments)) { + continue + } if ns, ok := namespaceTools[toolCall.Function.Name]; ok { outputs = append(outputs, ResponsesOutput{ Type: "function_call", @@ -1238,6 +1422,7 @@ type ChatCompletionsToResponsesStreamState struct { ResponseID string Model string Created int64 + ServiceTier string // upstream Chat chunk service_tier, echoed on response events SequenceNumber int CreatedSent bool CompletedSent bool @@ -1274,6 +1459,9 @@ type ChatCompletionsToResponsesStreamState struct { // 路由回它注册的 custom 工具。 CustomTools map[string]bool + // FunctionTools is the set of explicitly declared top-level function tools. + FunctionTools map[string]bool + // ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见 // HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按 // 该项类型(且 execution=client)执行 tool search。 @@ -1318,6 +1506,33 @@ func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToRe } } +// ValidateToolCallArguments checks the accumulated function-call arguments +// before the stream is finalized. A tool call whose argument stream was +// truncated must not be emitted as a completed Responses item: Codex will +// persist it and replay it on the next turn, where a Chat Completions provider +// rejects the whole request. +func (state *ChatCompletionsToResponsesStreamState) ValidateToolCallArguments() error { + if state == nil { + return nil + } + for idx, toolCall := range state.ToolCalls { + if toolCall == nil { + continue + } + if state.toolIsCustom[idx] || state.toolIsToolSearch[idx] { + continue + } + arguments := strings.TrimSpace(toolCall.Function.Arguments) + if arguments == "" { + continue + } + if !json.Valid([]byte(arguments)) { + return fmt.Errorf("tool call %q (%s) arguments are invalid JSON", toolCall.ID, toolCall.Function.Name) + } + } + return nil +} + func (state *ChatCompletionsToResponsesStreamState) allocOutputIndex() int { idx := state.nextOutputIndex state.nextOutputIndex++ @@ -1339,6 +1554,9 @@ func ChatCompletionsChunkToResponsesEvents( if state.Model == "" && chunk.Model != "" { state.Model = chunk.Model } + if chunk.ServiceTier != "" { + state.ServiceTier = chunk.ServiceTier + } if chunk.Usage != nil { state.Usage = ChatUsageToResponsesUsage(chunk.Usage) } @@ -1495,6 +1713,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr Object: "response", Model: state.Model, Status: status, + ServiceTier: state.ServiceTier, Output: state.chatOutput(), Usage: state.Usage, IncompleteDetails: incompleteDetails, @@ -1510,11 +1729,12 @@ func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState) state.CreatedSent = true return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{ Response: &ResponsesResponse{ - ID: state.ResponseID, - Object: "response", - Model: state.Model, - Status: "in_progress", - Output: []ResponsesOutput{}, + ID: state.ResponseID, + Object: "response", + Model: state.Model, + Status: "in_progress", + ServiceTier: state.ServiceTier, + Output: []ResponsesOutput{}, }, })} } @@ -1647,11 +1867,11 @@ func announceChatToolItem( if state.toolAnnounced[idx] { return nil } - if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || len(state.FunctionTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { return nil } state.toolAnnounced[idx] = true - isCustom := state.CustomTools[stored.Function.Name] + customName, isCustom := customToolCallName(stored.Function.Name, state.CustomTools, state.FunctionTools, state.NamespaceTools) isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName state.toolIsCustom[idx] = isCustom state.toolIsToolSearch[idx] = isToolSearch @@ -1665,6 +1885,9 @@ func announceChatToolItem( // namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要 // 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。 itemName, itemNamespace := stored.Function.Name, "" + if isCustom { + itemName = customName + } if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch { state.toolNamespace[idx] = ns itemName, itemNamespace = ns.Name, ns.Namespace @@ -1734,7 +1957,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response OutputIndex: outputIndex, ItemID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: input, }), chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ @@ -1743,7 +1966,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response Type: "custom_tool_call", ID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: input, Status: "completed", }, @@ -1834,7 +2057,7 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp Type: "custom_tool_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: extractCustomToolCallInput(arguments), Status: "completed", }) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index 945900d6d6..ebf23fd9f9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -34,6 +34,60 @@ func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *test assert.Equal(t, "wait", out.Tools[1].Function.Name) } +func TestResponsesChatBridge_MixedCustomAndNamespaceToolNames(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-test", + Input: json.RawMessage(`"run pwd"`), + Tools: []ResponsesTool{ + {Type: "custom", Name: "exec", Description: "Runs a command"}, + {Type: "namespace", Name: "functions", Tools: []ResponsesTool{ + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object"}`)}, + }}, + }, + } + + chatReq, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, chatReq.Tools, 2) + assert.Equal(t, "exec", chatReq.Tools[0].Function.Name) + assert.Equal(t, "functions__wait", chatReq.Tools[1].Function.Name) + + customTools := CustomToolNames(req.Tools) + namespaceTools := NamespaceToolNames(req.Tools) + resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{ + {ID: "call_exact", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}}, + {ID: "call_wait", Function: ChatFunctionCall{Name: "functions__wait", Arguments: `{"cell_id":"1"}`}}, + {ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}}, + }}}}} + + out := ChatCompletionsResponseToResponses(resp, req.Model, customTools, FunctionToolNames(req.Tools), false, namespaceTools) + require.Len(t, out.Output, 3) + assert.Equal(t, "custom_tool_call", out.Output[0].Type) + assert.Equal(t, "exec", out.Output[0].Name) + assert.Equal(t, "pwd", out.Output[0].Input) + assert.Equal(t, "function_call", out.Output[1].Type) + assert.Equal(t, "functions", out.Output[1].Namespace) + assert.Equal(t, "wait", out.Output[1].Name) + assert.Equal(t, "custom_tool_call", out.Output[2].Type) + assert.Equal(t, "exec", out.Output[2].Name) + assert.Equal(t, "not-json", out.Output[2].Input) +} + +func TestChatCompletionsResponseToResponses_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) { + resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{{ + ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`}, + }}}}}} + + out := ChatCompletionsResponseToResponses(resp, "deepseek-test", + map[string]bool{"exec": true}, map[string]bool{"functions__exec": true}, false, + map[string]NamespacedToolName{"functions__wait": {Namespace: "functions", Name: "wait"}}) + + require.Len(t, out.Output, 1) + assert.Equal(t, "function_call", out.Output[0].Type) + assert.Equal(t, "functions__exec", out.Output[0].Name) + assert.Equal(t, `{"path":"/tmp"}`, out.Output[0].Arguments) +} + func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) { req := &ResponsesRequest{ Model: "gpt-test", @@ -173,7 +227,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, nil, false, nil) require.Len(t, out.Output, 2) assert.Equal(t, "custom_tool_call", out.Output[0].Type) @@ -264,6 +318,68 @@ func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项") } +func TestChatCompletionsChunkToResponsesEvents_MixedCustomNamespaceAliasStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-test") + state.CustomTools = map[string]bool{"exec": true} + state.NamespaceTools = map[string]NamespacedToolName{ + "functions__wait": {Namespace: "functions", Name: "wait"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{ + Index: &idx, ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}, + }}}}}} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + for _, evt := range events { + if evt.Type == "response.output_item.added" && evt.Item != nil && evt.Item.Type == "custom_tool_call" { + assert.Equal(t, "exec", evt.Item.Name) + } + if evt.Type == "response.custom_tool_call_input.done" { + assert.Equal(t, "exec", evt.Name) + assert.Equal(t, "not-json", evt.Input) + } + if evt.Type == "response.output_item.done" && evt.Item != nil && evt.Item.Type == "custom_tool_call" { + assert.Equal(t, "exec", evt.Item.Name) + assert.Equal(t, "not-json", evt.Item.Input) + } + } + + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.Len(t, final.Response.Output, 1) + assert.Equal(t, "custom_tool_call", final.Response.Output[0].Type) + assert.Equal(t, "exec", final.Response.Output[0].Name) + assert.Equal(t, "not-json", final.Response.Output[0].Input) +} + +func TestChatCompletionsChunkToResponsesEvents_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-test") + state.CustomTools = map[string]bool{"exec": true} + state.FunctionTools = map[string]bool{"functions__exec": true} + state.NamespaceTools = map[string]NamespacedToolName{ + "functions__wait": {Namespace: "functions", Name: "wait"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{ + Index: &idx, ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`}, + }}}}}} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + for _, evt := range events { + assert.NotEqual(t, "response.custom_tool_call_input.done", evt.Type) + } + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.Len(t, final.Response.Output, 1) + assert.Equal(t, "function_call", final.Response.Output[0].Type) + assert.Equal(t, "functions__exec", final.Response.Output[0].Name) + assert.Equal(t, `{"path":"/tmp"}`, final.Response.Output[0].Arguments) +} + func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) { req := &ResponsesRequest{ Model: "glm-5.2", @@ -280,6 +396,18 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`) } +func TestResponsesToChatCompletionsRequest_DropsDeferredFlagWithToolSearch(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true}]}`), &req)) + + out, err := ResponsesToChatCompletionsRequest(&req) + require.NoError(t, err) + encoded, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(encoded), "defer_loading") + require.Contains(t, string(encoded), `"name":"tool_search"`) +} + // codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行 // tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配 // 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。 @@ -296,7 +424,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, true, nil) require.Len(t, out.Output, 1) item := out.Output[0] @@ -328,7 +456,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCa } // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。 - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nil) require.Len(t, out.Output, 1) assert.Equal(t, "function_call", out.Output[0].Type) } @@ -644,6 +772,23 @@ func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *test assert.Equal(t, "tool_search", out.Tools[0].Function.Name) } +func TestResponsesToChatCompletionsRequest_RejectsDuplicateTopLevelExecutableNames(t *testing.T) { + for _, tools := range [][]ResponsesTool{ + {{Type: "custom", Name: "exec"}, {Type: "function", Name: "exec"}}, + {{Type: "function", Name: "exec"}, {Type: "function", Name: "exec"}}, + {{Type: "custom", Name: "exec"}, {Type: "custom", Name: "exec"}}, + } { + _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: tools, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "exec") + assert.Contains(t, err.Error(), "cannot disambiguate") + } +} + // tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发, // chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。 func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) { @@ -795,7 +940,7 @@ func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testin "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nsTools) require.Len(t, out.Output, 2) item := out.Output[0] diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index 2e770b4158..6060d02166 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -17,6 +17,62 @@ func TestResponsesInputToChatMessages_DeveloperRoleMapsToSystem(t *testing.T) { assert.JSONEq(t, `"follow project instructions"`, string(messages[0].Content)) } +func TestResponsesInputToChatMessages_SkipsInvalidHistoricalFunctionCall(t *testing.T) { + input := json.RawMessage(`[ + {"type":"function_call","call_id":"call_bad","name":"exec_command","arguments":"{\"cmd\": \"ssh root@HOST"}, + {"type":"function_call_output","call_id":"call_bad","output":"failed to parse function arguments"}, + {"type":"function_call","call_id":"call_ok","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_ok","output":"ok"}, + {"role":"user","content":"continue"} + ]`) + + messages, err := responsesInputToChatMessages("", input) + require.NoError(t, err) + require.Len(t, messages, 3) + require.Equal(t, "assistant", messages[0].Role) + require.Len(t, messages[0].ToolCalls, 1) + require.Equal(t, "call_ok", messages[0].ToolCalls[0].ID) + require.Equal(t, "tool", messages[1].Role) + require.Equal(t, "call_ok", messages[1].ToolCallID) + require.Equal(t, "user", messages[2].Role) +} + +func TestResponsesInputToChatMessages_SkipsInvalidEmptyCallIDOutput(t *testing.T) { + input := json.RawMessage(`[ + {"type":"function_call","call_id":"","name":"exec_command","arguments":"{\"cmd\": \"ssh root@HOST"}, + {"type":"function_call_output","call_id":"","output":"failed to parse function arguments"}, + {"role":"user","content":"continue"} + ]`) + + messages, err := responsesInputToChatMessages("", input) + require.NoError(t, err) + require.Len(t, messages, 1) + require.Equal(t, "user", messages[0].Role) +} + +func TestChatCompletionsResponseToResponses_SkipsInvalidFunctionArguments(t *testing.T) { + resp := &ChatCompletionsResponse{ + Model: "deepseek-v4-flash", + Choices: []ChatChoice{{ + Message: ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{ + {ID: "call_bad", Type: "function", Function: ChatFunctionCall{Name: "exec_command", Arguments: `{"cmd": "ssh root@HOST`}}, + {ID: "call_ok", Type: "function", Function: ChatFunctionCall{Name: "exec_command", Arguments: `{}`}}, + }, + }, + FinishReason: "length", + }}, + } + + out := ChatCompletionsResponseToResponses(resp, "deepseek-v4-flash", nil, nil, false, nil) + require.Equal(t, "incomplete", out.Status) + require.Len(t, out.Output, 1) + require.Equal(t, "function_call", out.Output[0].Type) + require.Equal(t, "call_ok", out.Output[0].CallID) + require.Equal(t, `{}`, out.Output[0].Arguments) +} + func TestResponsesInputToChatMessages_KeepsChatCompletionRoles(t *testing.T) { input := json.RawMessage(`[ {"role":"system","content":"system message"}, diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go new file mode 100644 index 0000000000..dacedfc61d --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go @@ -0,0 +1,161 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// Encrypted-only reasoning items (empty summary + opaque encrypted_content, +// e.g. after codex remote compaction) carry no plaintext the bridge can map to +// reasoning_content. The gateway-side cache keyed by reasoning item id restores +// it; without the restore, DeepSeek thinking mode rejects the history with 400 +// "The `reasoning_content` in the thinking mode must be passed back to the API". +func TestResponsesToChat_ReasoningCacheLookup_RestoresEncryptedOnlyItem(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ]`), + } + + out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{ + ReasoningContentByID: func(itemID string) string { + if itemID == "item_enc1" { + return "cached thinking" + } + return "" + }, + }) + require.NoError(t, err) + require.Len(t, out.Messages, 3) + require.Equal(t, "assistant", out.Messages[0].Role) + require.Equal(t, "cached thinking", out.Messages[0].ReasoningContent) + require.Len(t, out.Messages[0].ToolCalls, 1) + require.Equal(t, "call_1", out.Messages[0].ToolCalls[0].ID) + require.Equal(t, "tool", out.Messages[1].Role) + require.Equal(t, "user", out.Messages[2].Role) +} + +// A cache miss keeps the original behavior: no reasoning_content, no error. +func TestResponsesToChat_ReasoningCacheLookup_MissKeepsOriginalBehavior(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_unknown","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ]`), + } + + out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{ + ReasoningContentByID: func(string) string { return "" }, + }) + require.NoError(t, err) + require.Len(t, out.Messages, 3) + require.Empty(t, out.Messages[0].ReasoningContent) + + // Nil options (legacy path) behaves identically. + legacy, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Equal(t, out.Messages, legacy.Messages) +} + +// Plaintext summary wins and the cache lookup is not consulted. +func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ]`), + } + + lookupCalled := false + out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{ + ReasoningContentByID: func(string) string { + lookupCalled = true + return "cached thinking" + }, + }) + require.NoError(t, err) + require.Len(t, out.Messages, 3) + require.Equal(t, "plain thinking", out.Messages[0].ReasoningContent) + require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run") +} + +// DeepSeek emits reasoning only once per turn; chained tool calls +// (reasoning → call A → output A → call B) have no reasoning item before call +// B. The turn's reasoning must be replayed on B's assistant message, otherwise +// DeepSeek thinking mode 400s the history ("reasoning_content ... must be +// passed back"). Reproduced from a real codex 0.147.0 resume history. +func TestResponsesToChat_ChainedToolCallsReplayTurnReasoning(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_r1","summary":[{"type":"summary_text","text":"turn thinking"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n\n"}]}, + {"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_a","output":"ok"}, + {"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_b","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}, + {"type":"reasoning","id":"item_r2","summary":[{"type":"summary_text","text":"second turn"}]}, + {"type":"function_call","call_id":"call_c","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_c","output":"ok"} + ]`), + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + + byCallID := map[string]ChatMessage{} + for _, m := range out.Messages { + for _, tc := range m.ToolCalls { + byCallID[tc.ID] = m + } + } + require.Len(t, byCallID, 3) + require.Equal(t, "turn thinking", byCallID["call_a"].ReasoningContent) + require.Equal(t, "turn thinking", byCallID["call_b"].ReasoningContent, + "链式第二个工具调用必须回放本轮 reasoning") + require.Equal(t, "second turn", byCallID["call_c"].ReasoningContent, + "user 消息后开启新轮次,不得沿用上一轮 reasoning") + + // 每一条 assistant 消息都必须带 reasoning_content(DeepSeek 契约)。 + for i, m := range out.Messages { + if m.Role == "assistant" { + require.NotEmpty(t, m.ReasoningContent, "messages[%d] 缺 reasoning_content", i) + } + } +} + +func TestExtractResponsesReasoningItem(t *testing.T) { + id, text, ok := ExtractResponsesReasoningItem(json.RawMessage( + `{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`)) + require.True(t, ok) + require.Equal(t, "item_a", id) + require.Equal(t, "think", text) + + // Encrypted-only item: ok with id but empty text. + id, text, ok = ExtractResponsesReasoningItem(json.RawMessage( + `{"type":"reasoning","id":"item_b","summary":[],"encrypted_content":"opaque"}`)) + require.True(t, ok) + require.Equal(t, "item_b", id) + require.Empty(t, text) + + // Non-reasoning items are skipped. + _, _, ok = ExtractResponsesReasoningItem(json.RawMessage( + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}`)) + require.False(t, ok) + + _, _, ok = ExtractResponsesReasoningItem(json.RawMessage(`"bare string"`)) + require.False(t, ok) +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go index baa4e4b2a5..1a986b60d9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go @@ -214,6 +214,78 @@ func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) { require.Equal(t, `{"cmd":"ls"}`, argsDelta.String()) } +func TestStream_InvalidToolArgumentsAreRejectedBeforeFinalize(t *testing.T) { + idx := 0 + state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash") + chunk := &ChatCompletionsChunk{ + Choices: []ChatChunkChoice{ + { + Index: 0, + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{ + { + Index: &idx, + ID: "call_bad", + Type: "function", + Function: ChatFunctionCall{ + Name: "exec_command", + Arguments: `{"cmd": "ssh root@HOST`, + }, + }, + }, + }, + }, + }, + } + ChatCompletionsChunkToResponsesEvents(chunk, state) + + err := state.ValidateToolCallArguments() + require.ErrorContains(t, err, "invalid JSON") +} + +func TestStream_ValidToolCallAtOutputLimitKeepsIncompleteResponse(t *testing.T) { + idx := 0 + state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash") + chunk := &ChatCompletionsChunk{ + Choices: []ChatChunkChoice{ + { + Index: 0, + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{ + { + Index: &idx, + ID: "call_at_limit", + Type: "function", + Function: ChatFunctionCall{ + Name: "exec_command", + Arguments: `{}`, + }, + }, + }, + }, + }, + }, + } + ChatCompletionsChunkToResponsesEvents(chunk, state) + state.FinishReason = "length" + + require.NoError(t, state.ValidateToolCallArguments()) + events := FinalizeChatCompletionsResponsesStream(state) + var sawArgsDone, sawIncomplete bool + for _, event := range events { + switch event.Type { + case "response.function_call_arguments.done": + sawArgsDone = true + require.Equal(t, `{}`, event.Arguments) + case "response.completed": + require.NotNil(t, event.Response) + sawIncomplete = event.Response.Status == "incomplete" + } + } + require.True(t, sawArgsDone) + require.True(t, sawIncomplete) +} + // TestStream_SSEWireComplete drives the full stream through SSE encoding and // asserts the function_call events carry complete fields on the wire. func TestStream_SSEWireComplete(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 4e319f9751..a57bd73f91 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -407,6 +407,79 @@ func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testi assert.Equal(t, "Describe this", parts[0].Text) } +func TestChatCompletionsToResponses_FilePartFileData(t *testing.T) { + content := `[{"type":"text","text":"Summarize the attached document"},{"type":"file","file":{"filename":"document.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQ="}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 2) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Summarize the attached document", parts[0].Text) + assert.Equal(t, "input_file", parts[1].Type) + assert.Equal(t, "document.pdf", parts[1].Filename) + assert.Equal(t, "data:application/pdf;base64,JVBERi0xLjQ=", parts[1].FileData) + assert.Empty(t, parts[1].FileID) +} + +func TestChatCompletionsToResponses_FilePartFileID(t *testing.T) { + content := `[{"type":"file","file":{"file_id":"file-abc123"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_file", parts[0].Type) + assert.Equal(t, "file-abc123", parts[0].FileID) + assert.Empty(t, parts[0].FileData) +} + +func TestChatCompletionsToResponses_EmptyFilePartSkipped(t *testing.T) { + // A file part with neither file_data nor file_id carries nothing the + // Responses API can use; dropping it (like empty image URLs) avoids an + // upstream 400 on an empty input_file part. + content := `[{"type":"text","text":"Describe this"},{"type":"file","file":{"filename":"empty.pdf"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_text", parts[0].Type) +} + func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) { // Regression for #2515: the upstream Responses API rejects an input item // whose content field is JSON null. Any chat-completions message that @@ -459,7 +532,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -493,7 +566,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 6a3c751f13..384b58495b 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -378,6 +378,15 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont ImageURL: p.ImageURL.URL, }) } + case "file": + if p.File != nil && (p.File.FileData != "" || p.File.FileID != "") { + responseParts = append(responseParts, ResponsesContentPart{ + Type: "input_file", + Filename: p.File.Filename, + FileData: p.File.FileData, + FileID: p.File.FileID, + }) + } } } return responseParts diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index daffc984d5..5e831c1c52 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -27,6 +27,13 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, if !ok || len(tools) == 0 { return ResponsesClientToolMapping{}, false, nil } + discovered, err := promoteResponsesToolSearchDiscoveries(req) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if discovered { + tools, _ = req["tools"].([]any) + } adapter := ResponsesClientToolMapping{CustomTools: make(map[string]bool)} functionNames := make(map[string]bool) @@ -73,7 +80,7 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, tools, _ = req["tools"].([]any) lowered := make([]any, 0, len(tools)) - changed := flattened + changed := discovered || flattened seenSearch := false for _, raw := range tools { tool, ok := raw.(map[string]any) @@ -112,10 +119,17 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, lowered = append(lowered, raw) } } + if stripResponsesDeferredToolFlags(lowered) { + changed = true + } if changed { req["tools"] = lowered } - if rewriteClientToolHistory(req["input"], &adapter) { + historyChanged, err := rewriteClientToolHistory(req["input"], &adapter) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if historyChanged { changed = true } if rewriteClientToolChoice(req, &adapter) { @@ -130,6 +144,70 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, return adapter, changed, nil } +// stripResponsesDeferredToolFlags removes defer_loading only when the final +// declaration list no longer contains the built-in tool_search it requires. +func stripResponsesDeferredToolFlags(tools []any) bool { + if hasResponsesToolSearchDeclaration(tools) { + return false + } + changed := false + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if _, exists := tool["defer_loading"]; exists { + delete(tool, "defer_loading") + changed = true + } + } + return changed +} + +// AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on +// a follow-up request that omits the session-level tools declaration. An +// explicitly present tools field, including an empty or malformed value, +// always replaces the inherited mapping and is handled by the ordinary +// declaration-driven adapter. +func AdaptResponsesClientToolsWithInheritedMapping( + req map[string]any, + inherited ResponsesClientToolMapping, + inheritedLoweredTools ...[]any, +) (ResponsesClientToolMapping, bool, error) { + if req == nil { + return ResponsesClientToolMapping{}, false, nil + } + if _, toolsPresent := req["tools"]; toolsPresent { + return AdaptResponsesClientTools(req) + } + if len(inherited.CustomTools) == 0 && !inherited.ToolSearch && len(inherited.NamespaceTools) == 0 { + return ResponsesClientToolMapping{}, false, nil + } + if len(inheritedLoweredTools) > 0 && len(inheritedLoweredTools[0]) > 0 { + req["tools"] = restoreInheritedResponsesClientToolDeclarations(inheritedLoweredTools[0], inherited) + return AdaptResponsesClientTools(req) + } + + changed, err := rewriteClientToolHistory(req["input"], &inherited) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if len(inherited.NamespaceTools) > 0 { + before := changed + rewriteNamespaceQualifiedCalls(req["input"], inherited.NamespaceTools) + // Namespace rewriting does not currently report whether it changed a + // value. A retained namespace mapping is only used for follow-up + // history, so conservatively rebuild the request when input exists. + if _, inputPresent := req["input"]; inputPresent && !before { + changed = true + } + } + if rewriteClientToolChoice(req, &inherited) { + changed = true + } + return inherited, changed, nil +} + func copyClientTool(tool map[string]any) map[string]any { copy := make(map[string]any, len(tool)) for key, value := range tool { @@ -138,14 +216,16 @@ func copyClientTool(tool map[string]any) map[string]any { return copy } -func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bool { +func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (bool, error) { changed := false - var visit func(any) - visit = func(value any) { + var visit func(any) error + visit = func(value any) error { switch typed := value.(type) { case []any: for _, item := range typed { - visit(item) + if err := visit(item); err != nil { + return err + } } case map[string]any: typ := strings.TrimSpace(stringValue(typed["type"])) @@ -155,10 +235,12 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo typed["type"] = "function_call" typed["arguments"] = customToolCallArguments(stringValue(typed["input"])) delete(typed, "input") + normalizeLoweredFunctionItemID(typed) changed = true } case "custom_tool_call_output": typed["type"] = "function_call_output" + normalizeLoweredFunctionItemID(typed) normalizeClientToolOutput(typed) changed = true case "tool_search_call": @@ -167,22 +249,105 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo typed["name"] = toolSearchProxyName typed["arguments"] = rawObjectString(typed["arguments"]) delete(typed, "execution") + normalizeLoweredFunctionItemID(typed) changed = true } case "tool_search_output": if adapter.ToolSearch { + callID := strings.TrimSpace(stringValue(typed["call_id"])) + if callID == "" { + return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output") + } typed["type"] = "function_call_output" - normalizeClientToolOutput(typed) + normalizeLoweredFunctionItemID(typed) + if err := normalizeToolSearchOutput(typed); err != nil { + return err + } changed = true } } for _, child := range typed { - visit(child) + if err := visit(child); err != nil { + return err + } } } + return nil + } + if err := visit(value); err != nil { + return false, err + } + return changed, nil +} + +// normalizeLoweredFunctionItemID reconciles Codex client-only item IDs such as +// ctc_*, ctco_*, tsc_*, and tso_* after their item type is lowered to the +// function protocol, which validates these IDs with the fc prefix. A ctc_/tsc_ +// call ID maps back to the fc_ ID it was raised from; the remaining IDs have no +// function-protocol counterpart and are dropped. call_id, which is preserved +// separately, stays the tool call/output pairing key either way. +func normalizeLoweredFunctionItemID(item map[string]any) { + id := strings.TrimSpace(stringValue(item["id"])) + if id == "" || strings.HasPrefix(id, "fc") { + return + } + // A ctc_/tsc_ call ID on a lowered call item is the one we minted from the + // upstream's own fc_ ID when the item was raised, so map it back instead of + // dropping it. Anything else has no known function-protocol counterpart. + if recovered := retypedResponsesToolCallItemID(id, "function_call"); recovered != id { + item["id"] = recovered + return + } + delete(item, "id") +} + +// responsesToolCallItemIDPrefixes lists the Responses item ID prefixes that are +// tied to a specific tool-call item type. +var responsesToolCallItemIDPrefixes = []string{"fc_", "ctc_", "tsc_"} + +// responsesToolCallItemIDPrefix reports the ID prefix the Responses API +// validates for itemType, or "" when the type constrains no prefix. +func responsesToolCallItemIDPrefix(itemType string) string { + switch itemType { + case "custom_tool_call": + return "ctc_" + case "tool_search_call": + return "tsc_" + case "function_call": + return "fc_" + default: + return "" + } +} + +// retypedResponsesToolCallItemID re-prefixes an upstream item ID so it agrees +// with the item type we raise it back to. A function-only upstream answers a +// lowered custom tool with an fc_ item ID; emitting that ID on the restored +// custom_tool_call poisons the client's history, because a later replay of the +// same item to an upstream that validates IDs fails with +// "Invalid 'input[N].id' ... Expected an ID that begins with 'ctc'". +// The suffix is preserved so the ID stays stable and unique per upstream item. +// IDs that carry no known tool-call prefix are left alone rather than guessed at. +func retypedResponsesToolCallItemID(id, itemType string) string { + want := responsesToolCallItemIDPrefix(itemType) + if want == "" || id == "" || strings.HasPrefix(id, want) { + return id + } + for _, known := range responsesToolCallItemIDPrefixes { + if known != want && strings.HasPrefix(id, known) { + return want + strings.TrimPrefix(id, known) + } + } + return id +} + +// retypeResponsesToolCallItemID applies retypedResponsesToolCallItemID to a +// decoded item map. +func retypeResponsesToolCallItemID(item map[string]any, itemType string) { + id := strings.TrimSpace(stringValue(item["id"])) + if retyped := retypedResponsesToolCallItemID(id, itemType); retyped != id { + item["id"] = retyped } - visit(value) - return changed } func normalizeClientToolOutput(item map[string]any) { @@ -205,6 +370,47 @@ func normalizeClientToolOutput(item map[string]any) { item["output"] = string(encoded) } +// normalizeToolSearchOutput converts both tool_search output wire shapes into +// the string output required by function_call_output. Older clients send an +// output field directly; newer Codex clients return discovered definitions in +// a top-level tools field. Codex treats that field's value as the tool output, +// so serialize the value directly rather than wrapping it in another object. +func normalizeToolSearchOutput(item map[string]any) error { + if output, hasOutput := item["output"]; hasOutput { + switch typed := output.(type) { + case string: + item["output"] = typed + case nil: + item["output"] = "" + default: + encoded, err := json.Marshal(typed) + if err != nil { + return fmt.Errorf("tool_search_output output cannot be encoded as function_call_output output: %w", err) + } + item["output"] = string(encoded) + } + dropToolSearchOutputPrivateFields(item) + return nil + } + tools, hasTools := item["tools"] + if !hasTools { + return fmt.Errorf("tool_search_output requires output or tools before it can be lowered to function_call_output") + } + encoded, err := json.Marshal(tools) + if err != nil { + return fmt.Errorf("tool_search_output tools cannot be encoded as function_call_output output: %w", err) + } + item["output"] = string(encoded) + dropToolSearchOutputPrivateFields(item) + return nil +} + +func dropToolSearchOutputPrivateFields(item map[string]any) { + delete(item, "tools") + delete(item, "status") + delete(item, "execution") +} + func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool { choice, ok := req["tool_choice"].(map[string]any) if !ok { @@ -285,12 +491,14 @@ func restoreClientToolValue(value any, adapter *ResponsesClientToolMapping) bool name := strings.TrimSpace(stringValue(typed["name"])) if adapter.CustomTools[name] { typed["type"] = "custom_tool_call" + retypeResponsesToolCallItemID(typed, "custom_tool_call") typed["input"] = extractCustomToolCallInput(rawObjectString(typed["arguments"])) delete(typed, "arguments") delete(typed, "namespace") changed = true } else if adapter.ToolSearch && name == toolSearchProxyName { typed["type"] = "tool_search_call" + retypeResponsesToolCallItemID(typed, "tool_search_call") typed["execution"] = "client" typed["arguments"] = json.RawMessage(toolSearchCallArgumentsJSON(rawObjectString(typed["arguments"]))) delete(typed, "name") @@ -317,12 +525,15 @@ type ResponsesClientToolStreamRestorer struct { } type responsesClientToolStreamCall struct { - kind string - name string - callID string - itemID string - outputIdx int - arguments strings.Builder + kind string + name string + // callID and itemID stay as the upstream sent them so later upstream + // events keep matching this call; clientItemID is what we emit. + callID string + itemID string + clientItemID string + outputIdx int + arguments strings.Builder } func NewResponsesClientToolStreamRestorer(mapping ResponsesClientToolMapping) *ResponsesClientToolStreamRestorer { @@ -361,6 +572,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent) event.Item.Arguments = "{}" event.Item.Namespace = "" } + if call.clientItemID != "" { + event.Item.ID = call.clientItemID + } } emit(r.restoreNamespaceEvent(event)) case "response.function_call_arguments.delta": @@ -378,9 +592,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent) if call.kind == "custom" { input := extractCustomToolCallInput(call.arguments.String()) if input != "" { - emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.itemID, Delta: input}) + emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.clientItemID, Delta: input}) } - emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.itemID, CallID: call.callID, Name: call.name, Input: input}) + emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.clientItemID, CallID: call.callID, Name: call.name, Input: input}) } return out } @@ -401,6 +615,9 @@ func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent) } event.Item.Namespace = "" } + if call.clientItemID != "" { + event.Item.ID = call.clientItemID + } delete(r.calls, call.itemID) delete(r.calls, call.callID) delete(r.byOutput, call.outputIdx) @@ -430,7 +647,7 @@ func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]by if err := json.Unmarshal(payload, &wire); err != nil { return nil, false, err } - if wire.Type == "response.completed" || wire.Type == "response.incomplete" || wire.Type == "response.failed" { + if isResponsesClientToolTerminalEvent(wire.Type) { restored, changed, err := RestoreResponsesClientToolPayload(payload, r.adapter) if err != nil { return nil, false, err @@ -465,6 +682,15 @@ func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]by return result, true, nil } +func isResponsesClientToolTerminalEvent(typ string) bool { + switch strings.TrimSpace(typ) { + case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled": + return true + default: + return false + } +} + func (r *ResponsesClientToolStreamRestorer) clientToolEventPayload(payload []byte) bool { var raw struct { ItemID string `json:"item_id"` @@ -528,6 +754,15 @@ func (r *ResponsesClientToolStreamRestorer) resequenceRaw(payload []byte, sequen return [][]byte{encoded}, true, nil } +// responsesClientToolItemType maps a restorer call kind to the item type the +// client sees. +func responsesClientToolItemType(kind string) string { + if kind == "custom" { + return "custom_tool_call" + } + return "tool_search_call" +} + func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEvent) *responsesClientToolStreamCall { if event.Item == nil || event.Item.Type != "function_call" { return nil @@ -548,7 +783,14 @@ func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEven } call := r.calls[key] if call == nil { - call = &responsesClientToolStreamCall{kind: kind, name: name, callID: event.Item.CallID, itemID: event.Item.ID, outputIdx: event.OutputIndex} + call = &responsesClientToolStreamCall{ + kind: kind, + name: name, + callID: event.Item.CallID, + itemID: event.Item.ID, + clientItemID: retypedResponsesToolCallItemID(event.Item.ID, responsesClientToolItemType(kind)), + outputIdx: event.OutputIndex, + } r.calls[key] = call if call.callID != "" { r.calls[call.callID] = call @@ -602,11 +844,13 @@ func restoreResponsesOutputClientTools(outputs []ResponsesOutput, adapter *Respo } if adapter.CustomTools[output.Name] { output.Type = "custom_tool_call" + output.ID = retypedResponsesToolCallItemID(output.ID, output.Type) output.Input = extractCustomToolCallInput(output.Arguments) output.Arguments = "" output.Namespace = "" } else if adapter.ToolSearch && output.Name == toolSearchProxyName { output.Type = "tool_search_call" + output.ID = retypedResponsesToolCallItemID(output.ID, output.Type) output.Name = "" output.Namespace = "" } diff --git a/backend/internal/pkg/apicompat/responses_client_tools_item_id_helper_test.go b/backend/internal/pkg/apicompat/responses_client_tools_item_id_helper_test.go new file mode 100644 index 0000000000..66448ed661 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_client_tools_item_id_helper_test.go @@ -0,0 +1,29 @@ +package apicompat + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRetypedResponsesToolCallItemID(t *testing.T) { + for _, tc := range []struct { + name string + id string + itemType string + want string + }{ + {"function id raised to custom", "fc_abc", "custom_tool_call", "ctc_abc"}, + {"function id raised to tool search", "fc_abc", "tool_search_call", "tsc_abc"}, + {"already correct is untouched", "ctc_abc", "custom_tool_call", "ctc_abc"}, + {"custom id lowered to function", "ctc_abc", "function_call", "fc_abc"}, + {"unknown prefix is left alone", "item_abc", "custom_tool_call", "item_abc"}, + {"unprefixed id is left alone", "abc", "custom_tool_call", "abc"}, + {"empty id stays empty", "", "custom_tool_call", ""}, + {"unconstrained item type is left alone", "fc_abc", "message", "fc_abc"}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, retypedResponsesToolCallItemID(tc.id, tc.itemType)) + }) + } +} diff --git a/backend/internal/pkg/apicompat/responses_client_tools_item_id_test.go b/backend/internal/pkg/apicompat/responses_client_tools_item_id_test.go new file mode 100644 index 0000000000..724f77b75d --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_client_tools_item_id_test.go @@ -0,0 +1,132 @@ +package apicompat + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// A function-only upstream answers a lowered custom tool with an fc_ item ID. +// Emitting that ID on the restored custom_tool_call breaks the client the next +// time the item is replayed to an upstream that validates item IDs: +// "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with 'ctc'". +func TestRestoreResponsesClientToolPayload_RetypesToolCallItemIDs(t *testing.T) { + mapping := ResponsesClientToolMapping{ + CustomTools: map[string]bool{"exec": true}, ToolSearch: true, + NamespaceTools: map[string]ResponsesNamespaceName{"team__send": {Namespace: "team", Name: "send"}}, + } + payload := []byte(`{"id":"resp","output":[` + + `{"type":"function_call","id":"fc_abc123","call_id":"call_1","name":"exec","arguments":"{\"input\":\"dir\"}"},` + + `{"type":"function_call","id":"fc_def456","call_id":"call_2","name":"tool_search","arguments":"{\"query\":\"git\"}"},` + + `{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"team__send","arguments":"{}"}]}`) + + restored, changed, err := RestoreResponsesClientToolPayload(payload, mapping) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"id":"resp","output":[`+ + `{"type":"custom_tool_call","id":"ctc_abc123","call_id":"call_1","name":"exec","input":"dir"},`+ + `{"type":"tool_search_call","id":"tsc_def456","call_id":"call_2","execution":"client","arguments":{"query":"git"}},`+ + `{"type":"function_call","id":"fc_ghi789","call_id":"call_3","name":"send","namespace":"team","arguments":"{}"}]}`, + string(restored)) +} + +func TestRestoreResponsesOutputClientTools_RetypesToolCallItemIDs(t *testing.T) { + mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}, ToolSearch: true} + outputs := []ResponsesOutput{ + {Type: "function_call", ID: "fc_abc123", CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`}, + {Type: "function_call", ID: "fc_def456", CallID: "call_2", Name: toolSearchProxyName, Arguments: `{"query":"git"}`}, + } + + restoreResponsesOutputClientTools(outputs, &mapping) + + require.Equal(t, "custom_tool_call", outputs[0].Type) + require.Equal(t, "ctc_abc123", outputs[0].ID) + require.Equal(t, "call_1", outputs[0].CallID) + require.Equal(t, "tool_search_call", outputs[1].Type) + require.Equal(t, "tsc_def456", outputs[1].ID) + require.Equal(t, "call_2", outputs[1].CallID) +} + +func TestResponsesClientToolStreamRestorer_RetypesCustomToolCallItemID(t *testing.T) { + const upstreamID = "fc_09f77ac43cf7db36016a8920e7934487" + const clientID = "ctc_09f77ac43cf7db36016a8920e7934487" + + restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}) + + added := restorer.Restore(ResponsesStreamEvent{ + Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Status: "in_progress"}, + }) + require.Len(t, added, 1) + require.Equal(t, "custom_tool_call", added[0].Item.Type) + require.Equal(t, clientID, added[0].Item.ID) + + // Later upstream events still address the item by its upstream ID. + require.Empty(t, restorer.Restore(ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", SequenceNumber: 1, ItemID: upstreamID, Delta: `{"input":"di`, + })) + done := restorer.Restore(ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", SequenceNumber: 2, ItemID: upstreamID, + CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`, + }) + require.Len(t, done, 2) + require.Equal(t, "response.custom_tool_call_input.delta", done[0].Type) + require.Equal(t, clientID, done[0].ItemID) + require.Equal(t, "response.custom_tool_call_input.done", done[1].Type) + require.Equal(t, clientID, done[1].ItemID) + require.Equal(t, "call_1", done[1].CallID) + + closed := restorer.Restore(ResponsesStreamEvent{ + Type: "response.output_item.done", SequenceNumber: 3, OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", ID: upstreamID, CallID: "call_1", Name: "exec", Arguments: `{"input":"dir"}`, Status: "completed"}, + }) + require.Len(t, closed, 1) + require.Equal(t, "custom_tool_call", closed[0].Item.Type) + require.Equal(t, clientID, closed[0].Item.ID) + require.Equal(t, "dir", closed[0].Item.Input) +} + +func TestResponsesClientToolStreamRestorer_RetypesToolSearchCallItemID(t *testing.T) { + restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{ToolSearch: true}) + + added := restorer.Restore(ResponsesStreamEvent{ + Type: "response.output_item.added", SequenceNumber: 0, OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", ID: "fc_search1", CallID: "call_1", Name: toolSearchProxyName, Status: "in_progress"}, + }) + require.Len(t, added, 1) + require.Equal(t, "tool_search_call", added[0].Item.Type) + require.Equal(t, "tsc_search1", added[0].Item.ID) +} + +// The WS bridge replays restored items back to the upstream, so the ID we hand +// the client has to map back to the upstream's own fc_ ID on the way down. +func TestAdaptResponsesClientTools_RecoversRetypedToolCallItemID(t *testing.T) { + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "custom", "name": "exec"}, + map[string]any{"type": "tool_search"}, + }, + "input": []any{ + map[string]any{"type": "custom_tool_call", "id": "ctc_upstream1", "call_id": "call_1", "name": "exec", "input": "dir"}, + map[string]any{"type": "tool_search_call", "id": "tsc_upstream2", "call_id": "call_2", "arguments": map[string]any{"query": "git"}}, + map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "call_1", "output": "ok"}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + + input := requireResponsesClientToolValue[[]any](t, req["input"]) + require.Len(t, input, 3) + customCall := requireResponsesClientToolValue[map[string]any](t, input[0]) + require.Equal(t, "function_call", customCall["type"]) + require.Equal(t, "fc_upstream1", customCall["id"]) + searchCall := requireResponsesClientToolValue[map[string]any](t, input[1]) + require.Equal(t, "function_call", searchCall["type"]) + require.Equal(t, "fc_upstream2", searchCall["id"]) + // Output items have no function-protocol ID counterpart and stay dropped. + customOutput := requireResponsesClientToolValue[map[string]any](t, input[2]) + require.Equal(t, "function_call_output", customOutput["type"]) + require.NotContains(t, customOutput, "id") +} diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 91ee0890a6..bfe3fd2eb7 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -17,10 +17,10 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( }, "tool_choice": map[string]any{"type": "custom", "name": "exec"}, "input": []any{ - map[string]any{"type": "custom_tool_call", "call_id": "c1", "name": "exec", "input": "dir"}, - map[string]any{"type": "custom_tool_call_output", "call_id": "c1", "output": "ok"}, - map[string]any{"type": "tool_search_call", "call_id": "s1", "arguments": map[string]any{"query": "git"}}, - map[string]any{"type": "tool_search_output", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}}, + map[string]any{"type": "custom_tool_call", "id": "ctc_client", "call_id": "c1", "name": "exec", "input": "dir"}, + map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "c1", "output": "ok"}, + map[string]any{"type": "tool_search_call", "id": "tsc_client", "call_id": "s1", "arguments": map[string]any{"query": "git"}}, + map[string]any{"type": "tool_search_output", "id": "tso_client", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}}, map[string]any{"type": "function_call", "call_id": "n1", "namespace": "team", "name": "send", "arguments": "{}"}, }, } @@ -48,20 +48,370 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( input := requireResponsesClientToolValue[[]any](t, req["input"]) customCall := requireResponsesClientToolValue[map[string]any](t, input[0]) require.Equal(t, "function_call", customCall["type"]) + require.Equal(t, "fc_client", customCall["id"]) require.JSONEq(t, `{"input":"dir"}`, requireResponsesClientToolValue[string](t, customCall["arguments"])) customOutput := requireResponsesClientToolValue[map[string]any](t, input[1]) require.Equal(t, "function_call_output", customOutput["type"]) + require.NotContains(t, customOutput, "id") searchCall := requireResponsesClientToolValue[map[string]any](t, input[2]) require.Equal(t, "function_call", searchCall["type"]) + require.Equal(t, "fc_client", searchCall["id"]) require.Equal(t, toolSearchProxyName, searchCall["name"]) require.JSONEq(t, `{"query":"git"}`, requireResponsesClientToolValue[string](t, searchCall["arguments"])) searchOutput := requireResponsesClientToolValue[map[string]any](t, input[3]) require.Equal(t, "function_call_output", searchOutput["type"]) + require.NotContains(t, searchOutput, "id") require.JSONEq(t, `{"groups":["git"]}`, requireResponsesClientToolValue[string](t, searchOutput["output"])) namespaceCall := requireResponsesClientToolValue[map[string]any](t, input[4]) require.Equal(t, "team__send", namespaceCall["name"]) } +func TestAdaptResponsesClientTools_RemovesDeferredFlagsWhenToolSearchIsLowered(t *testing.T) { + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + map[string]any{"type": "function", "name": "apply_patch"}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, toolSearchProxyName, requireResponsesClientToolValue[map[string]any](t, tools[0])["name"]) + require.NotContains(t, requireResponsesClientToolValue[map[string]any](t, tools[1]), "defer_loading") +} + +func TestStripResponsesDeferredToolFlags_PreservesFlagsWithBuiltInToolSearch(t *testing.T) { + tools := []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + } + + require.False(t, stripResponsesDeferredToolFlags(tools)) + require.Equal(t, true, requireResponsesClientToolValue[map[string]any](t, tools[1])["defer_loading"]) +} + +func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) { + requestJSON := `{ + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","id":"tsc_client","call_id":"call_search","arguments":{"query":"codex app"},"execution":"client","status":"completed"}, + {"type":"tool_search_output","id":"tso_client","call_id":"call_search","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}} + ]} + ]} + ] + }` + + type adaptedRequest struct { + req map[string]any + mapping ResponsesClientToolMapping + } + adapt := func() adaptedRequest { + var req map[string]any + require.NoError(t, json.Unmarshal([]byte(requestJSON), &req)) + mapping, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + return adaptedRequest{req: req, mapping: mapping} + } + + first := adapt() + second := adapt() + firstInput := requireResponsesClientToolValue[[]any](t, first.req["input"]) + secondInput := requireResponsesClientToolValue[[]any](t, second.req["input"]) + + tools := requireResponsesClientToolValue[[]any](t, first.req["tools"]) + require.Len(t, tools, 4) + require.Equal(t, []string{ + "tool_search", + "codex_app__load_workspace_dependencies", + "multi_agent_v1__spawn_agent", + "multi_agent_v1__wait_agent", + }, responsesClientToolNames(t, tools)) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, first.mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, first.mapping.NamespaceTools["multi_agent_v1__wait_agent"]) + + call := requireResponsesClientToolValue[map[string]any](t, firstInput[0]) + require.Equal(t, "function_call", call["type"]) + require.Equal(t, toolSearchProxyName, call["name"]) + require.JSONEq(t, `{"query":"codex app"}`, requireResponsesClientToolValue[string](t, call["arguments"])) + require.NotContains(t, call, "execution") + + output := requireResponsesClientToolValue[map[string]any](t, firstInput[1]) + require.Equal(t, map[string]any{ + "type": "function_call_output", + "call_id": "call_search", + "output": output["output"], + }, output) + outputText := requireResponsesClientToolValue[string](t, output["output"]) + require.JSONEq(t, `[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}} + ]} + ]`, outputText) + secondOutput := requireResponsesClientToolValue[map[string]any](t, secondInput[1]) + require.Equal(t, outputText, secondOutput["output"], "tool discovery output encoding must be deterministic") + + restored, changed, err := RestoreResponsesClientToolPayload( + []byte(`{"output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`), + first.mapping, + ) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"output":[{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`, string(restored)) +} + +func TestAdaptResponsesClientTools_PromotesDirectDiscoveryAndDeduplicatesIdenticalDeclarations(t *testing.T) { + direct := map[string]any{ + "type": "function", "name": "inspect_result", "description": "Inspect a result", + "parameters": map[string]any{"type": "object", "properties": map[string]any{}}, + } + custom := map[string]any{ + "type": "custom", "name": "run_script", "description": "Run a script", + "format": map[string]any{"type": "grammar"}, + } + namespace := map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + } + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}}, + map[string]any{"type": "tool_search"}, + }, + "input": []any{ + map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_1", "tools": []any{direct, custom, namespace}}, + map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_2", "tools": []any{copyClientTool(direct), copyClientTool(custom), copyClientTool(namespace)}}, + }, + } + + mapping, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + require.True(t, mapping.CustomTools["run_script"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{"static_first", "tool_search", "inspect_result", "run_script", "multi_agent_v1__spawn_agent"}, responsesClientToolNames(t, tools)) + customTool := requireResponsesClientToolValue[map[string]any](t, tools[3]) + require.Equal(t, "function", customTool["type"]) + require.NotContains(t, customTool, "format") + for _, raw := range requireResponsesClientToolValue[[]any](t, req["input"]) { + item := requireResponsesClientToolValue[map[string]any](t, raw) + require.Equal(t, "function_call_output", item["type"]) + require.NotContains(t, item, "tools") + require.NotContains(t, item, "status") + } +} + +func TestAdaptResponsesClientTools_RejectsDiscoveredSchemaAndNamespaceCollisions(t *testing.T) { + tests := []struct { + name string + staticTools []any + discovered []any + }{ + { + name: "direct schema collision", + staticTools: []any{map[string]any{ + "type": "function", "name": "inspect", "parameters": map[string]any{"type": "object"}, + }}, + discovered: []any{map[string]any{ + "type": "function", "name": "inspect", "parameters": map[string]any{"type": "string"}, + }}, + }, + { + name: "namespace schema collision", + staticTools: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + }}, + discovered: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "string"}, + }}, + }}, + }, + { + name: "flattened namespace collision", + staticTools: []any{map[string]any{"type": "function", "name": "multi_agent_v1__spawn_agent"}}, + discovered: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := map[string]any{ + "tools": append(tt.staticTools, map[string]any{"type": "tool_search"}), + "input": []any{map[string]any{ + "type": "tool_search_output", "status": "completed", "tools": tt.discovered, + }}, + } + _, _, err := AdaptResponsesClientTools(req) + require.ErrorContains(t, err, "conflicts") + }) + } +} + +func TestAdaptResponsesClientTools_DoesNotPromoteUnusableDiscoveries(t *testing.T) { + req := map[string]any{ + "tools": []any{map[string]any{"type": "tool_search"}}, + "input": []any{ + map[string]any{"type": "tool_search_output", "call_id": "search_in_progress", "status": "in_progress", "tools": []any{map[string]any{"type": "function", "name": "not_ready"}}}, + map[string]any{"type": "tool_search_output", "call_id": "search_malformed", "status": "completed", "tools": []any{map[string]any{"type": "function"}}}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed, "the static tool_search declaration is still lowered") + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{"tool_search"}, responsesClientToolNames(t, tools)) +} + +func responsesClientToolNames(t *testing.T, tools []any) []string { + t.Helper() + names := make([]string, 0, len(tools)) + for _, raw := range tools { + tool := requireResponsesClientToolValue[map[string]any](t, raw) + names = append(names, requireResponsesClientToolValue[string](t, tool["name"])) + } + return names +} + +func TestAdaptResponsesClientTools_ToolSearchOutputEdgeCases(t *testing.T) { + unencodableOutput := make(chan struct{}) + tests := []struct { + name string + item map[string]any + wantOutput any + wantOutputExists bool + wantPrivateKeys []string + wantExactOutput bool + wantErr bool + }{ + { + name: "absent tools and output is rejected", + item: map[string]any{"type": "tool_search_output", "call_id": "call_empty", "status": "completed"}, + wantOutputExists: false, + wantErr: true, + }, + { + name: "preexisting string output wins", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_legacy", "output": "legacy", + "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, "execution": "client", + }, + wantOutput: "legacy", + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "preexisting object output remains legacy representation", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_object", "output": map[string]any{"groups": []any{"github"}}, + "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, + }, + wantOutput: `{"groups":["github"]}`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "unencodable preexisting output is rejected", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_bad_output", "output": unencodableOutput, + "tools": []any{map[string]any{"type": "function", "name": "retained"}}, "status": "completed", "execution": "client", + }, + wantOutput: unencodableOutput, + wantErr: true, + }, + { + name: "empty tools array is a valid empty output", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_empty_tools", + "tools": []any{}, "status": "completed", "execution": "client", + }, + wantOutput: `[]`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "non-array tools value is serialized directly", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_malformed", + "tools": map[string]any{"unexpected": true}, "status": "completed", "execution": "client", + }, + wantOutput: `{"unexpected":true}`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "unencodable tools is rejected", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_unencodable", "tools": make(chan struct{}), "status": "completed", + }, + wantErr: true, + }, + { + name: "missing call id is rejected", + item: map[string]any{ + "type": "tool_search_output", "tools": []any{}, "status": "completed", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := map[string]any{ + "tools": []any{map[string]any{"type": "tool_search"}}, + "input": []any{tt.item}, + } + _, changed, err := AdaptResponsesClientTools(req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.True(t, changed) + input := requireResponsesClientToolValue[[]any](t, req["input"]) + output := requireResponsesClientToolValue[map[string]any](t, input[0]) + require.Equal(t, "function_call_output", output["type"]) + actualOutput, outputExists := output["output"] + require.Equal(t, tt.wantOutputExists, outputExists) + if tt.wantOutputExists { + require.Equal(t, tt.wantOutput, actualOutput) + } + if tt.wantExactOutput { + require.Equal(t, map[string]any{ + "type": "function_call_output", + "call_id": output["call_id"], + "output": tt.wantOutput, + }, output) + } + if len(tt.wantPrivateKeys) > 0 { + for _, key := range tt.wantPrivateKeys { + require.Contains(t, output, key) + } + } else { + require.NotContains(t, output, "tools") + require.NotContains(t, output, "status") + require.NotContains(t, output, "execution") + } + }) + } +} + func requireResponsesClientToolValue[T any](t *testing.T, value any) T { t.Helper() typed, ok := value.(T) @@ -81,6 +431,122 @@ func TestAdaptResponsesClientTools_RejectsAmbiguousNames(t *testing.T) { } } +func TestAdaptResponsesClientToolsWithInheritedMapping_LowersFollowupHistoryWithoutTools(t *testing.T) { + req := map[string]any{ + "input": []any{ + map[string]any{ + "type": "custom_tool_call", "name": "exec", + "call_id": "call_1", "input": "pwd", + }, + map[string]any{ + "type": "custom_tool_call_output", "call_id": "call_1", + "id": "ctco_client_output_1", + "output": []any{map[string]any{"type": "input_text", "text": "ok"}}, + }, + }, + } + inherited := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}} + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, inherited, mapping) + items := requireResponsesClientToolValue[[]any](t, req["input"]) + call := requireResponsesClientToolValue[map[string]any](t, items[0]) + require.Equal(t, "function_call", call["type"]) + require.JSONEq(t, `{"input":"pwd"}`, requireResponsesClientToolValue[string](t, call["arguments"])) + require.NotContains(t, call, "input") + output := requireResponsesClientToolValue[map[string]any](t, items[1]) + require.Equal(t, "function_call_output", output["type"]) + require.NotContains(t, output, "id") + require.JSONEq(t, `[{"text":"ok","type":"input_text"}]`, requireResponsesClientToolValue[string](t, output["output"])) +} + +func TestAdaptResponsesClientToolsWithInheritedMapping_PromotesOmittedToolsDiscoveryIntoEffectiveDeclarations(t *testing.T) { + req := map[string]any{ + "input": []any{map[string]any{ + "type": "tool_search_output", "call_id": "call_search", "status": "completed", "execution": "client", + "tools": []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + }}, + }}, + } + inherited := ResponsesClientToolMapping{ + ToolSearch: true, + NamespaceTools: map[string]ResponsesNamespaceName{ + "codex_app__read_resource": {Namespace: "codex_app", Name: "read_resource"}, + }, + } + lowered := []any{ + map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}}, + map[string]any{"type": "function", "name": "tool_search", "parameters": json.RawMessage(toolSearchProxySchema)}, + map[string]any{"type": "function", "name": "codex_app__read_resource", "parameters": map[string]any{"type": "object"}}, + } + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited, lowered) + require.NoError(t, err) + require.True(t, changed) + require.True(t, mapping.ToolSearch) + require.Equal(t, ResponsesNamespaceName{Namespace: "codex_app", Name: "read_resource"}, mapping.NamespaceTools["codex_app__read_resource"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{ + "static_first", "tool_search", "codex_app__read_resource", "multi_agent_v1__spawn_agent", + }, responsesClientToolNames(t, tools)) + output := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) + require.Equal(t, "function_call_output", output["type"]) + require.IsType(t, "", output["output"]) + require.NotContains(t, output, "tools") + require.NotContains(t, output, "status") + require.NotContains(t, output, "execution") +} + +func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInheritedMapping(t *testing.T) { + req := map[string]any{ + "tools": []any{}, + "input": []any{map[string]any{ + "type": "custom_tool_call", "name": "exec", "input": "pwd", + }}, + } + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping( + req, + ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}, + ) + + require.NoError(t, err) + require.False(t, changed) + require.Empty(t, mapping) + items := requireResponsesClientToolValue[[]any](t, req["input"]) + call := requireResponsesClientToolValue[map[string]any](t, items[0]) + require.Equal(t, "custom_tool_call", call["type"]) +} + +func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolResetDoesNotPromoteDiscovery(t *testing.T) { + for _, reset := range []any{nil, []any{}} { + req := map[string]any{ + "tools": reset, + "input": []any{map[string]any{ + "type": "tool_search_output", "call_id": "call_reset", "status": "completed", + "tools": []any{map[string]any{"type": "function", "name": "must_not_promote"}}, + }}, + } + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping( + req, + ResponsesClientToolMapping{ToolSearch: true}, + []any{map[string]any{"type": "function", "name": "tool_search"}}, + ) + require.NoError(t, err) + require.False(t, changed) + require.Empty(t, mapping) + item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) + require.Equal(t, "tool_search_output", item["type"]) + } +} + func TestRestoreResponsesClientToolPayload_RestoresClientAndNamespaceCalls(t *testing.T) { mapping := ResponsesClientToolMapping{ CustomTools: map[string]bool{"exec": true}, ToolSearch: true, @@ -174,3 +640,30 @@ func TestResponsesClientToolStreamRestorer_RawEventsPreserveUnknownFieldsAndOutp require.Len(t, done, 2) require.Equal(t, "pwd", done[1].Input) } + +func TestResponsesClientToolStreamRestorer_RestoresAllTerminalEvents(t *testing.T) { + for _, eventType := range []string{ + "response.completed", + "response.done", + "response.incomplete", + "response.failed", + "response.cancelled", + "response.canceled", + } { + t.Run(eventType, func(t *testing.T) { + restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}) + payload := []byte(`{"type":"` + eventType + `","sequence_number":7,"response":{"id":"resp_tools","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}]}}`) + + restored, changed, err := restorer.RestoreEvent(payload) + + require.NoError(t, err) + require.True(t, changed) + require.Len(t, restored, 1) + require.Equal(t, eventType, gjson.GetBytes(restored[0], "type").String()) + require.Equal(t, int64(7), gjson.GetBytes(restored[0], "sequence_number").Int()) + require.Equal(t, "custom_tool_call", gjson.GetBytes(restored[0], "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(restored[0], "response.output.0.input").String()) + require.False(t, gjson.GetBytes(restored[0], "response.output.0.arguments").Exists()) + }) + } +} diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index a89a1b4203..d288b31bea 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -23,10 +23,11 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } out := &ChatCompletionsResponse{ - ID: id, - Object: "chat.completion", - Created: time.Now().Unix(), - Model: model, + ID: id, + Object: "chat.completion", + Created: time.Now().Unix(), + Model: model, + ServiceTier: resp.ServiceTier, } var contentText string @@ -118,6 +119,7 @@ type ResponsesEventToChatState struct { ID string Model string Created int64 + ServiceTier string // upstream tier observed on response events; echoed on chunks SentRole bool SawToolCall bool SawText bool @@ -187,12 +189,13 @@ func FinalizeResponsesChatStream(state *ResponsesEventToChatState) []ChatComplet if state.IncludeUsage && state.Usage != nil { chunks = append(chunks, ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, - Choices: []ChatChunkChoice{}, - Usage: state.Usage, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, + Choices: []ChatChunkChoice{}, + Usage: state.Usage, }) } @@ -218,6 +221,9 @@ func resToChatHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToCh if state.Model == "" && evt.Response.Model != "" { state.Model = evt.Response.Model } + if evt.Response.ServiceTier != "" { + state.ServiceTier = evt.Response.ServiceTier + } } // Emit the role chunk. if state.SentRole { @@ -301,6 +307,9 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo if evt.Response.Usage != nil { state.Usage = chatUsageFromResponsesUsage(evt.Response.Usage) } + if evt.Response.ServiceTier != "" { + state.ServiceTier = evt.Response.ServiceTier + } switch evt.Response.Status { case "incomplete": @@ -326,12 +335,13 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo if state.IncludeUsage && state.Usage != nil { chunks = append(chunks, ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, - Choices: []ChatChunkChoice{}, - Usage: state.Usage, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, + Choices: []ChatChunkChoice{}, + Usage: state.Usage, }) } @@ -401,10 +411,11 @@ func completionDetailsFromResponses(src *ResponsesOutputTokensDetails) *ChatToke func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatCompletionsChunk { return ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, Choices: []ChatChunkChoice{{ Index: 0, Delta: delta, @@ -416,10 +427,11 @@ func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatC func makeChatFinishChunk(state *ResponsesEventToChatState, finishReason string) ChatCompletionsChunk { empty := "" return ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, Choices: []ChatChunkChoice{{ Index: 0, Delta: ChatDelta{Content: &empty}, diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions_tool_name_test.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions_tool_name_test.go new file mode 100644 index 0000000000..c30ca97389 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions_tool_name_test.go @@ -0,0 +1,44 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestResponsesEventToChatChunks_ArgumentsDeltaOmitsEmptyName pins that +// streamed arguments-only deltas never re-emit the tool name as an empty +// string. OpenAI-compatible clients accumulate the name from the first +// tool-call delta; a trailing "name":"" would overwrite the accumulated name +// and break tool dispatch ("unknown tool"). +func TestResponsesEventToChatChunks_ArgumentsDeltaOmitsEmptyName(t *testing.T) { + state := NewResponsesEventToChatState() + + added := ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{ + Type: "function_call", + CallID: "call_a", + Name: "exec", + }, + }, state) + require.Len(t, added, 1) + first, err := json.Marshal(added[0]) + require.NoError(t, err) + require.Contains(t, string(first), `"name":"exec"`) + + for _, fragment := range []string{`{"cmd":"ls"}`, `{"cmd":"ls","flags":"-la"}`} { + deltas := ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 0, + Delta: fragment, + }, state) + require.Len(t, deltas, 1) + raw, err := json.Marshal(deltas[0]) + require.NoError(t, err) + require.NotContains(t, string(raw), `"name"`, "arguments delta must not carry a name field") + require.Contains(t, string(raw), `"arguments"`) + } +} diff --git a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go new file mode 100644 index 0000000000..2617d5f940 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go @@ -0,0 +1,263 @@ +package apicompat + +import ( + "encoding/json" + "fmt" + "strings" +) + +type responsesDiscoveredToolIdentity struct { + typ string + name string + namespace string + encoded string + ambiguous bool +} + +// promoteResponsesToolSearchDiscoveries makes successfully discovered client +// tools callable for function-only upstreams. The original tool_search_output +// remains request history and is normalized separately; declarations are +// appended after static tools so the client's declaration order stays stable. +func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { + tools, ok := req["tools"].([]any) + if !ok || len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) { + return false, nil + } + input, ok := req["input"].([]any) + if !ok || len(input) == 0 { + return false, nil + } + + known := make(map[string]responsesDiscoveredToolIdentity) + for _, raw := range tools { + registerExistingResponsesToolIdentity(known, raw) + } + + promoted := make([]any, 0) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(stringValue(item["type"])) != "tool_search_output" || !usableResponsesToolSearchOutput(item) { + continue + } + discoveries, ok := item["tools"].([]any) + if !ok || len(discoveries) == 0 { + continue + } + if _, err := json.Marshal(discoveries); err != nil { + continue + } + for _, rawDiscovery := range discoveries { + discovery, ok := rawDiscovery.(map[string]any) + if !ok { + continue + } + typ := strings.TrimSpace(stringValue(discovery["type"])) + switch typ { + case "function", "custom": + copy, identity, ok := responsesDirectToolDiscovery(discovery, typ) + if !ok { + continue + } + appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity) + if err != nil { + return false, err + } + if appendTool { + promoted = append(promoted, copy) + } + case "namespace": + copy, identities, ok := responsesNamespaceToolDiscovery(discovery) + if !ok { + continue + } + children := make([]any, 0, len(identities)) + for _, candidate := range identities { + appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity) + if err != nil { + return false, err + } + if appendTool { + children = append(children, candidate.child) + } + } + if len(children) > 0 { + copy["tools"] = children + delete(copy, "children") + promoted = append(promoted, copy) + } + } + } + } + if len(promoted) == 0 { + return false, nil + } + req["tools"] = append(tools, promoted...) + return true, nil +} + +func hasResponsesToolSearchDeclaration(tools []any) bool { + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if ok && strings.TrimSpace(stringValue(tool["type"])) == "tool_search" { + return true + } + } + return false +} + +func usableResponsesToolSearchOutput(item map[string]any) bool { + status, present := item["status"] + if !present { + return true + } + text, ok := status.(string) + return ok && strings.TrimSpace(text) == "completed" +} + +func registerExistingResponsesToolIdentity(known map[string]responsesDiscoveredToolIdentity, raw any) { + tool, ok := raw.(map[string]any) + if !ok { + return + } + typ := strings.TrimSpace(stringValue(tool["type"])) + switch typ { + case "function", "custom": + _, identity, ok := responsesDirectToolDiscovery(tool, typ) + if ok { + registerExistingResponsesIdentity(known, identity.name, identity) + } + case "namespace": + _, identities, ok := responsesNamespaceToolDiscovery(tool) + if !ok { + return + } + for _, candidate := range identities { + registerExistingResponsesIdentity(known, candidate.flat, candidate.identity) + } + } +} + +func registerExistingResponsesIdentity(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) { + previous, exists := known[key] + if !exists { + known[key] = identity + return + } + if !sameResponsesDiscoveredTool(previous, identity) { + previous.ambiguous = true + known[key] = previous + } +} + +func admitResponsesDiscoveredTool(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) (bool, error) { + previous, exists := known[key] + if !exists { + known[key] = identity + return true, nil + } + if !previous.ambiguous && sameResponsesDiscoveredTool(previous, identity) { + return false, nil + } + return false, fmt.Errorf("discovered tool %q conflicts with an existing declaration; this upstream cannot safely disambiguate different names, namespaces, or schemas", key) +} + +func sameResponsesDiscoveredTool(left, right responsesDiscoveredToolIdentity) bool { + return left.typ == right.typ && left.name == right.name && left.namespace == right.namespace && left.encoded == right.encoded +} + +func responsesDirectToolDiscovery(tool map[string]any, typ string) (map[string]any, responsesDiscoveredToolIdentity, bool) { + name := strings.TrimSpace(stringValue(tool["name"])) + if name == "" { + return nil, responsesDiscoveredToolIdentity{}, false + } + copy := copyClientTool(tool) + copy["type"] = typ + copy["name"] = name + identityCopy := copy + if typ == "custom" { + identityCopy = copyClientTool(copy) + identityCopy["type"] = "function" + identityCopy["parameters"] = json.RawMessage(customToolInputSchema) + delete(identityCopy, "format") + } + encoded, err := json.Marshal(identityCopy) + if err != nil { + return nil, responsesDiscoveredToolIdentity{}, false + } + return copy, responsesDiscoveredToolIdentity{typ: typ, name: name, encoded: string(encoded)}, true +} + +// restoreInheritedResponsesClientToolDeclarations reverses only the declaration +// identities recorded by ResponsesClientToolMapping. It is used when a WS +// continuation omits tools but the HTTP function upstream still needs the +// effective session declarations on every request. +func restoreInheritedResponsesClientToolDeclarations(lowered []any, mapping ResponsesClientToolMapping) []any { + restored := make([]any, 0, len(lowered)) + for _, raw := range lowered { + tool, ok := raw.(map[string]any) + if !ok { + restored = append(restored, raw) + continue + } + name := strings.TrimSpace(stringValue(tool["name"])) + switch { + case mapping.ToolSearch && name == toolSearchProxyName: + restored = append(restored, map[string]any{"type": "tool_search"}) + case mapping.CustomTools[name]: + copy := copyClientTool(tool) + copy["type"] = "custom" + restored = append(restored, copy) + case mapping.NamespaceTools[name].Namespace != "": + identity := mapping.NamespaceTools[name] + child := copyClientTool(tool) + child["type"] = "function" + child["name"] = identity.Name + restored = append(restored, map[string]any{ + "type": "namespace", "name": identity.Namespace, "tools": []any{child}, + }) + default: + restored = append(restored, copyClientTool(tool)) + } + } + return restored +} + +type responsesNamespaceToolCandidate struct { + flat string + child map[string]any + identity responsesDiscoveredToolIdentity +} + +func responsesNamespaceToolDiscovery(tool map[string]any) (map[string]any, []responsesNamespaceToolCandidate, bool) { + namespace := strings.TrimSpace(stringValue(tool["name"])) + children := namespaceChildren(tool) + if namespace == "" || len(children) == 0 { + return nil, nil, false + } + copy := copyClientTool(tool) + copy["type"] = "namespace" + copy["name"] = namespace + identities := make([]responsesNamespaceToolCandidate, 0, len(children)) + for _, rawChild := range children { + child, ok := rawChild.(map[string]any) + if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" { + continue + } + childCopy, direct, ok := responsesDirectToolDiscovery(child, "function") + if !ok { + continue + } + flat := flattenNamespaceToolName(namespace, direct.name) + identities = append(identities, responsesNamespaceToolCandidate{ + flat: flat, + child: childCopy, + identity: responsesDiscoveredToolIdentity{ + typ: "namespace", name: direct.name, namespace: namespace, encoded: direct.encoded, + }, + }) + } + if len(identities) == 0 { + return nil, nil, false + } + return copy, identities, true +} diff --git a/backend/internal/pkg/apicompat/service_tier_passthrough_test.go b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go new file mode 100644 index 0000000000..6c8dd0205c --- /dev/null +++ b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go @@ -0,0 +1,175 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// 上游响应中的 service_tier 必须如实回传,不被重写/丢弃: +// 非流式(ResponsesResponse → ChatCompletionsResponse)与流式 chunk 均覆盖。 + +func TestResponsesToChatCompletions_PreservesUpstreamServiceTier(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_1", + Object: "response", + Model: "gpt-5.5", + Status: "completed", + ServiceTier: "priority", + Output: []ResponsesOutput{{ + Type: "message", + Role: "assistant", + Content: []ResponsesContentPart{{ + Type: "output_text", + Text: "hi", + }}, + }}, + Usage: &ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + } + + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Equal(t, "priority", chat.ServiceTier) + + // 序列化后字段仍在(omitempty 不丢非空值)。 + raw, err := json.Marshal(chat) + require.NoError(t, err) + require.Contains(t, string(raw), `"service_tier":"priority"`) +} + +func TestResponsesToChatCompletions_OmitsMissingServiceTier(t *testing.T) { + resp := &ResponsesResponse{ID: "resp_1", Model: "gpt-5.5", Status: "completed"} + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Empty(t, chat.ServiceTier) + raw, err := json.Marshal(chat) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +func TestResponsesEventToChatChunks_PreservesUpstreamServiceTier(t *testing.T) { + state := NewResponsesEventToChatState() + state.IncludeUsage = true + + created := &ResponsesStreamEvent{Type: "response.created"} + require.NoError(t, json.Unmarshal([]byte(`{"type":"response.created","response":{"id":"resp_s1","model":"gpt-5.5","service_tier":"priority","status":"in_progress"}}`), created)) + + chunks := ResponsesEventToChatChunks(created, state) + require.NotEmpty(t, chunks) + for _, chunk := range chunks { + require.Equal(t, "priority", chunk.ServiceTier) + } + + // 后续 delta chunk 继续携带(OpenAI 流式 chunk 的 service_tier 语义)。 + delta := &ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "hi"} + chunks = ResponsesEventToChatChunks(delta, state) + require.NotEmpty(t, chunks) + require.Equal(t, "priority", chunks[0].ServiceTier) + + // 终止事件同样携带。 + completed := &ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{ + ID: "resp", Model: "gpt-5.5", Status: "completed", + Usage: &ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + }} + chunks = ResponsesEventToChatChunks(completed, state) + require.NotEmpty(t, chunks) + for _, chunk := range chunks { + require.Equal(t, "priority", chunk.ServiceTier) + } +} + +func TestResponsesEventToChatChunks_NoServiceTierStaysClean(t *testing.T) { + state := NewResponsesEventToChatState() + created := &ResponsesStreamEvent{Type: "response.created", Response: &ResponsesResponse{ID: "resp", Model: "gpt-5.5"}} + chunks := ResponsesEventToChatChunks(created, state) + require.NotEmpty(t, chunks) + require.Empty(t, chunks[0].ServiceTier) + raw, err := json.Marshal(chunks[0]) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +// 上游 JSON 反序列化时 service_tier 进入 ResponsesResponse(缓冲桥读取链路)。 +func TestResponsesResponse_UnmarshalPreservesServiceTier(t *testing.T) { + var resp ResponsesResponse + require.NoError(t, json.Unmarshal([]byte(`{"id":"resp_1","object":"response","model":"gpt-5.5","status":"completed","service_tier":"flex","output":[]}`), &resp)) + require.Equal(t, "flex", resp.ServiceTier) +} + +// --------------------------------------------------------------------------- +// 反向转换(Chat-only fallback):CC 响应/流 chunk 的 service_tier 保留到 +// Responses 形态,客户端与计费都能拿到上游回显。 +// --------------------------------------------------------------------------- + +func TestChatCompletionsResponseToResponses_PreservesServiceTier(t *testing.T) { + cc := &ChatCompletionsResponse{ + ID: "chatcmpl-1", + Model: "gpt-5.5", + ServiceTier: "default", + Choices: []ChatChoice{{ + Index: 0, + Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, + FinishReason: "stop", + }}, + Usage: &ChatUsage{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, + } + resp := ChatCompletionsResponseToResponses(cc, "gpt-5.5", nil, nil, false, nil) + require.Equal(t, "default", resp.ServiceTier) + + raw, err := json.Marshal(resp) + require.NoError(t, err) + require.Contains(t, string(raw), `"service_tier":"default"`) +} + +func TestChatCompletionsResponseToResponses_NilRespOmitsServiceTier(t *testing.T) { + resp := ChatCompletionsResponseToResponses(nil, "gpt-5.5", nil, nil, false, nil) + require.Empty(t, resp.ServiceTier) +} + +func TestChatCompletionsChunkToResponsesEvents_PreservesServiceTier(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("gpt-5.5") + chunk := &ChatCompletionsChunk{ + ID: "chatcmpl-2", + Model: "gpt-5.5", + ServiceTier: "flex", + Choices: []ChatChunkChoice{{ + Index: 0, + Delta: ChatDelta{Content: strPtr("hi")}, + }}, + } + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + require.NotEmpty(t, events) + + // response.created 携带 service_tier。 + created := findEvent(events, "response.created") + require.NotNil(t, created) + require.NotNil(t, created.Response) + require.Equal(t, "flex", created.Response.ServiceTier) + + // 终止事件同样携带。 + final := FinalizeChatCompletionsResponsesStream(state) + completed := findEvent(final, "response.completed") + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.Equal(t, "flex", completed.Response.ServiceTier) +} + +func TestChatCompletionsChunkToResponsesEvents_NoTierStaysClean(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("gpt-5.5") + chunk := &ChatCompletionsChunk{ID: "chatcmpl-3", Model: "gpt-5.5"} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + created := findEvent(events, "response.created") + require.NotNil(t, created) + require.Empty(t, created.Response.ServiceTier) + raw, err := json.Marshal(created) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +func findEvent(events []ResponsesStreamEvent, eventType string) *ResponsesStreamEvent { + for i := range events { + if events[i].Type == eventType { + return &events[i] + } + } + return nil +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index bcc8cd481e..7ed9133e1b 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -155,12 +155,26 @@ func AnthropicStopReasonString(p *string) string { return *p } +// AnthropicPromptTokensDetails holds OpenAI-compatible prompt token details +// occasionally included by Anthropic-compatible providers. +type AnthropicPromptTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` +} + // AnthropicUsage holds token counts in Anthropic format. type AnthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` + // Anthropic-compatible providers can also expose their native OpenAI-style + // total/cache fields. Preserve them so callers can normalize provider totals + // into Anthropic's mutually-exclusive billing buckets. + PromptTokens int `json:"prompt_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` + PromptTokensDetails *AnthropicPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens,omitempty"` + PromptCacheMissTokens *int `json:"prompt_cache_miss_tokens,omitempty"` } // --------------------------------------------------------------------------- @@ -295,9 +309,14 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { - Type string `json:"type"` // "input_text" | "output_text" | "input_image" + Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" Text string `json:"text,omitempty"` ImageURL string `json:"image_url,omitempty"` // data URI for input_image + + // input_file fields. + Filename string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` // data URI + FileID string `json:"file_id,omitempty"` } // ResponsesTool describes a tool in the Responses API. @@ -339,12 +358,13 @@ func (t *ResponsesTool) UnmarshalJSON(data []byte) error { // ResponsesResponse is the non-streaming response from POST /v1/responses. type ResponsesResponse struct { - ID string `json:"id"` - Object string `json:"object"` // "response" - Model string `json:"model"` - Status string `json:"status"` // "completed" | "incomplete" | "failed" - Output []ResponsesOutput `json:"output"` - Usage *ResponsesUsage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` // "response" + Model string `json:"model"` + Status string `json:"status"` // "completed" | "incomplete" | "failed" + Output []ResponsesOutput `json:"output"` + Usage *ResponsesUsage `json:"usage,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` // upstream tier, echoed back verbatim // incomplete_details is present when status="incomplete" IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"` @@ -670,9 +690,10 @@ type ChatMessage struct { // ChatContentPart is a typed content part in a multi-modal message. type ChatContentPart struct { - Type string `json:"type"` // "text" | "image_url" + Type string `json:"type"` // "text" | "image_url" | "file" Text string `json:"text,omitempty"` ImageURL *ChatImageURL `json:"image_url,omitempty"` + File *ChatFile `json:"file,omitempty"` } // ChatImageURL contains the URL for an image content part. @@ -681,6 +702,13 @@ type ChatImageURL struct { Detail string `json:"detail,omitempty"` // "auto" | "low" | "high" } +// ChatFile contains the payload of a "file" content part (e.g. PDF input). +type ChatFile struct { + Filename string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` // data URI + FileID string `json:"file_id,omitempty"` +} + // ChatTool describes a tool available to the model. type ChatTool struct { Type string `json:"type"` // "function" | "x_search" @@ -714,7 +742,9 @@ type ChatToolCall struct { // ChatFunctionCall contains the function name and arguments. type ChatFunctionCall struct { - Name string `json:"name"` + // Empty name is omitted so streamed arguments-only deltas never overwrite + // the tool name a client accumulated from the first delta. + Name string `json:"name,omitempty"` Arguments string `json:"arguments"` } diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go new file mode 100644 index 0000000000..ab587a4c01 --- /dev/null +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -0,0 +1,69 @@ +package claude + +import ( + "strings" + "unicode" +) + +var ( + effortLowMediumHigh = []string{"low", "medium", "high"} + effortLowMediumHighMax = []string{"low", "medium", "high", "max"} + effortLowMediumHighXHighMax = []string{"low", "medium", "high", "xhigh", "max"} +) + +var effortFamilies = []struct { + family string + levels []string +}{ + {family: "claude-mythos-preview", levels: effortLowMediumHighMax}, + {family: "claude-mythos-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-fable-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-sonnet-4-6", levels: effortLowMediumHighMax}, + {family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-4-6", levels: effortLowMediumHighMax}, + {family: "claude-opus-4-5", levels: effortLowMediumHigh}, + {family: "claude-opus-5", levels: effortLowMediumHighXHighMax}, +} + +// EffortLevelsForModel returns the output_config.effort values accepted by a +// Claude model, ordered from the lightest to the deepest reasoning level. +func EffortLevelsForModel(model string) []string { + id := normalizeEffortModelID(model) + for _, entry := range effortFamilies { + if id == entry.family || strings.HasPrefix(id, entry.family+"-") { + return append([]string(nil), entry.levels...) + } + } + return nil +} + +func normalizeEffortModelID(model string) string { + id := strings.ToLower(strings.TrimSpace(model)) + id = strings.TrimPrefix(id, "models/") + if slash := strings.IndexByte(id, '/'); slash >= 0 { + id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/") + } + id = strings.TrimPrefix(id, "anthropic.") + id = strings.TrimSuffix(id, "-thinking") + if mapped, ok := ModelIDReverseOverrides[id]; ok { + id = mapped + } + if len(id) >= 9 { + suffix := id[len(id)-9:] + if suffix[0] == '-' { + digits := true + for _, r := range suffix[1:] { + if !unicode.IsDigit(r) { + digits = false + break + } + } + if digits { + id = id[:len(id)-9] + } + } + } + return id +} diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go new file mode 100644 index 0000000000..718e20eb57 --- /dev/null +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -0,0 +1,29 @@ +package claude + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEffortLevelsForModel(t *testing.T) { + t.Parallel() + + tests := []struct { + model string + want []string + }{ + {model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}}, + {model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}}, + {model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}}, + {model: "claude-haiku-4-5-20251001", want: nil}, + {model: "gpt-5.6", want: nil}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, EffortLevelsForModel(tt.model)) + }) + } +} diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go index bbd9a6c464..cc9cbc80b2 100644 --- a/backend/internal/pkg/geminicli/models.go +++ b/backend/internal/pkg/geminicli/models.go @@ -22,5 +22,24 @@ var DefaultModels = []Model{ {ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""}, } +// GoogleOneModels is the conservative model set exposed for legacy Google One +// OAuth accounts. Newer subscription models are served through Antigravity +// OAuth rather than the retired consumer Gemini CLI / Code Assist channel. +var GoogleOneModels = []Model{ + {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, + {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, + {ID: "gemini-2.0-flash", Type: "model", DisplayName: "Gemini 2.0 Flash", CreatedAt: ""}, +} + +// GoogleOneModelMapping returns a new whitelist map for each account so callers +// cannot mutate the package-level catalog. +func GoogleOneModelMapping() map[string]string { + mapping := make(map[string]string, len(GoogleOneModels)) + for _, model := range GoogleOneModels { + mapping[model.ID] = model.ID + } + return mapping +} + // DefaultTestModel is the default model to preselect in test flows. const DefaultTestModel = "gemini-2.0-flash" diff --git a/backend/internal/pkg/geminicli/models_test.go b/backend/internal/pkg/geminicli/models_test.go index c1884e2e10..e0cb57941f 100644 --- a/backend/internal/pkg/geminicli/models_test.go +++ b/backend/internal/pkg/geminicli/models_test.go @@ -21,3 +21,24 @@ func TestDefaultModels_ContainsImageModels(t *testing.T) { } } } + +func TestGoogleOneModels_ExcludeUnsupportedNewAndImageModels(t *testing.T) { + t.Parallel() + + mapping := GoogleOneModelMapping() + for _, id := range []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"} { + if mapping[id] != id { + t.Fatalf("expected Google One model %q to map to itself", id) + } + } + for _, id := range []string{"gemini-2.5-flash-image", "gemini-3.1-flash-image", "gemini-3.5-flash"} { + if _, ok := mapping[id]; ok { + t.Fatalf("did not expect unsupported Google One model %q", id) + } + } + + mapping["gemini-2.5-flash"] = "mutated" + if GoogleOneModelMapping()["gemini-2.5-flash"] != "gemini-2.5-flash" { + t.Fatal("GoogleOneModelMapping must return a defensive copy") + } +} diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index a863a19f81..772143ac92 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -45,6 +45,9 @@ func DefaultModelIDs() []string { // DefaultTestModel default model for testing OpenAI accounts const DefaultTestModel = "gpt-5.4" +// CodexUsageProbeModel is the model used for OAuth Codex usage probes. +const CodexUsageProbeModel = "codex-auto-review" + // DefaultInstructions default instructions for non-Codex CLI requests. // 内容为真实 Codex CLI 的 GPT-5-Codex base prompt(codex 系模型默认)。 // diff --git a/backend/internal/pkg/servertiming/http.go b/backend/internal/pkg/servertiming/http.go index e326e24302..2ad8a04ae1 100644 --- a/backend/internal/pkg/servertiming/http.go +++ b/backend/internal/pkg/servertiming/http.go @@ -53,10 +53,10 @@ func Do(client *http.Client, req *http.Request) (*http.Response, error) { client = http.DefaultClient } if req == nil || !Active(req.Context()) { - return client.Do(req) + return client.Do(req) //nolint:gosec // G704: 通用埋点包装器,不构造 URL;请求由调用方构造,SSRF 信任边界在调用方 } startedAt := time.Now() - response, err := client.Do(req) + response, err := client.Do(req) //nolint:gosec // G704: 同上 RecordDependency(req.Context(), dependencyModule(req), startedAt, time.Now()) return response, err } diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index a100b2ccaa..ad97026e2b 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -115,11 +115,12 @@ type EndpointStat struct { ActualCost float64 `json:"actual_cost"` // 实际扣除 } -// GroupUsageSummary represents today's and cumulative cost for a single group. +// GroupUsageSummary represents today's, yesterday's, and cumulative cost for a single group. type GroupUsageSummary struct { - GroupID int64 `json:"group_id"` - TodayCost float64 `json:"today_cost"` - TotalCost float64 `json:"total_cost"` + GroupID int64 `json:"group_id"` + TodayCost float64 `json:"today_cost"` + YesterdayCost float64 `json:"yesterday_cost"` + TotalCost float64 `json:"total_cost"` } // GroupStat represents usage statistics for a single group diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index 44e4772d44..39e1a2c8b7 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -19,7 +19,7 @@ const ( // repository and service layers build their own client identity from it, so // one bump here covers OAuth traffic and billing probes together. // Keep in sync with https://x.ai/cli/stable. - CLIClientVersion = "0.2.114" + CLIClientVersion = "0.2.120" // billingCLIUserAgent is the legacy pager/shell UA used by billing probes. // Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA). billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)" diff --git a/backend/internal/pkg/xai/cli_identity_test.go b/backend/internal/pkg/xai/cli_identity_test.go index 521df7483d..2057b9effd 100644 --- a/backend/internal/pkg/xai/cli_identity_test.go +++ b/backend/internal/pkg/xai/cli_identity_test.go @@ -41,7 +41,7 @@ func TestApplyCLIProxyHeaders(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") ApplyCLIProxyHeaders(req) @@ -56,12 +56,12 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") ApplyCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("x-grok-client-identifier")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) } diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 990a6d28e0..65634598f1 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -51,24 +51,25 @@ type Model struct { // DefaultTextModel is the built-in fallback for empty model fields and Grok // text aliases (e.g. "grok", "grok-latest"). Operators may override the runtime // default via settings key grok_default_text_model. -const DefaultTextModel = "grok-4.5" +const DefaultTextModel = "grok-4.6" // Official Imagine model IDs (https://docs.x.ai/docs/models). const ( DefaultImagineImageQualityModel = "grok-imagine-image-quality" DefaultImagineImageFastModel = "grok-imagine-image" + DefaultImagineImage20Model = "grok-imagine-image-2.0" DefaultImagineVideoModel = "grok-imagine-video" - DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5" - DefaultImagineVideo15Model = "grok-imagine-video-1.5-preview" + DefaultImagineVideo15Model = "grok-imagine-video-1.5" + DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5-preview" ) // ModelMappingOptions controls optional expansions of the default mapping. // Cross-client wildcards (gpt-*/claude-*) default ON via settings // grok_cross_client_model_map_enabled so Codex/Claude clients keep working -// against Grok groups (map to DefaultText / grok-4.5). Operators may disable. +// against Grok groups (map to DefaultText / grok-4.6). Operators may disable. type ModelMappingOptions struct { // DefaultText is the target for empty models and optional cross-client maps. - // Empty → DefaultTextModel (grok-4.5). + // Empty → DefaultTextModel (grok-4.6). DefaultText string // EnableCrossClientMap merges gpt-*/codex-*/o*/claude-* → DefaultText. EnableCrossClientMap bool @@ -86,8 +87,6 @@ var defaultModels = []Model{ {ID: "grok-4.6", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.6"}, {ID: "grok-4.5", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"}, {ID: "grok-4.3", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"}, - {ID: "grok-3-mini", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini"}, - {ID: "grok-3-mini-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini Fast"}, {ID: "grok-build-0.1", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"}, {ID: "grok-composer-2.5-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"}, {ID: "grok-4.20-0309-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"}, @@ -96,9 +95,9 @@ var defaultModels = []Model{ // Imagine {ID: DefaultImagineImageQualityModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"}, {ID: DefaultImagineImageFastModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"}, + {ID: DefaultImagineImage20Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image 2.0"}, {ID: DefaultImagineVideoModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"}, - {ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Preview"}, - {ID: DefaultImagineVideo15LegacyModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Legacy"}, + {ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"}, } // grokTextResponsesModelAliases is the source of truth for Grok text models @@ -109,14 +108,14 @@ var grokTextResponsesModelAliases = map[string]string{ "grok-latest": DefaultTextModel, "grok-4.6": "grok-4.6", "grok-4.6-latest": "grok-4.6", - "grok-4.5": DefaultTextModel, - "grok-4.5-latest": DefaultTextModel, + "grok-4.5": "grok-4.5", + "grok-4.5-latest": "grok-4.5", "grok-4.3": "grok-4.3", "grok-4.3-latest": "grok-4.3", "grok-3-mini": "grok-3-mini", "grok-3-mini-fast": "grok-3-mini-fast", "grok-build": "grok-build-0.1", - "grok-build-latest": DefaultTextModel, + "grok-build-latest": "grok-build-0.1", "grok-build-0.1": "grok-build-0.1", "grok-composer-2.5-fast": "grok-composer-2.5-fast", "grok-composer": "grok-composer-2.5-fast", @@ -162,7 +161,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { } for alias, canonical := range grokTextResponsesModelAliases { // Remap aliases that pointed at DefaultTextModel constant to runtime default. - if canonical == DefaultTextModel { + if (alias == "grok" || alias == "grok-latest") && canonical == DefaultTextModel { mapping[alias] = defaultText } else { mapping[alias] = canonical @@ -179,7 +178,7 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { // Keep official IDs as identity so client-requested model strings are not // rewritten on the wire (pricing still canonicalizes 1.5* via CanonicalImagineVideoModel). mapping["grok-imagine-video"] = DefaultImagineVideoModel - mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15LegacyModel + mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15Model mapping["grok-imagine-video-1.5-preview"] = DefaultImagineVideo15Model // Informal alias only: mapping["grok-video-1.5"] = DefaultImagineVideo15Model @@ -252,6 +251,30 @@ func IsGrokModelID(model string) bool { return false } +// IsGrokImagineModel reports whether model is a Grok Imagine image or video +// model. These media models cannot act as the primary Codex agent model. +func IsGrokImagineModel(model string) bool { + normalized := strings.ToLower(StripGrokProviderPrefix(model)) + if normalized == "" { + return false + } + if strings.HasPrefix(normalized, "imagine") { + return true + } + switch { + case normalized == "grok-imagine", + normalized == "grok-imagine-1", + normalized == "grok-imagine-edit", + normalized == "grok-video-1.5": + return true + case strings.HasPrefix(normalized, "grok-imagine-image"), + strings.HasPrefix(normalized, "grok-imagine-video"): + return true + default: + return false + } +} + // IsGrokTextResponsesModelID reports whether model is a known Grok text model // for the Responses API. Imagine image/video and unknown custom ids return false. func IsGrokTextResponsesModelID(model string) bool { @@ -273,7 +296,7 @@ func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string } normalized := strings.ToLower(StripGrokProviderPrefix(trimmed)) if canonical, ok := grokTextResponsesModelAliases[normalized]; ok { - if canonical == DefaultTextModel { + if (normalized == "grok" || normalized == "grok-latest") && canonical == DefaultTextModel { return fallback } return canonical diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index a01d993ccb..f9d58e5a0c 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -12,14 +12,14 @@ func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) { SetRuntimeModelMappingOptions(ModelMappingOptions{}) mapping := DefaultModelMapping() - require.Equal(t, "grok-4.5", mapping["grok"]) - require.Equal(t, "grok-4.5", mapping["grok-latest"]) + require.Equal(t, "grok-4.6", mapping["grok"]) + require.Equal(t, "grok-4.6", mapping["grok-latest"]) require.Equal(t, "grok-build-0.1", mapping["grok-build"]) - require.Equal(t, DefaultTextModel, mapping["grok-build-latest"]) + require.Equal(t, "grok-build-0.1", mapping["grok-build-latest"]) require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) - require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"]) + require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) - require.Equal(t, "grok-4.5", mapping["xai/grok"]) + require.Equal(t, "grok-4.6", mapping["xai/grok"]) // Cross-vendor wildcards must stay opt-in. _, hasGPT := mapping["gpt-*"] @@ -58,6 +58,16 @@ func TestIsGrokModelID(t *testing.T) { require.False(t, IsGrokModelID("claude-sonnet-4")) } +func TestIsGrokImagineModel(t *testing.T) { + t.Parallel() + require.True(t, IsGrokImagineModel("grok-imagine-image")) + require.True(t, IsGrokImagineModel("grok-imagine-video-1.5-preview")) + require.True(t, IsGrokImagineModel("xai/grok-imagine-image-quality")) + require.True(t, IsGrokImagineModel("grok-video-1.5")) + require.False(t, IsGrokImagineModel("grok-4.6")) + require.False(t, IsGrokImagineModel("grok-build-0.1")) +} + func TestDefaultModelsIncludesGrok46(t *testing.T) { t.Parallel() ids := DefaultModelIDs() @@ -68,7 +78,19 @@ func TestDefaultModelsIncludesGrok46(t *testing.T) { func TestResolveGrokTextResponsesModelID(t *testing.T) { t.Parallel() - require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("")) require.Equal(t, "grok-4.3", ResolveGrokTextResponsesModelID("grok", "grok-4.3")) require.Equal(t, "grok-4.20-multi-agent-0309", ResolveGrokTextResponsesModelID("grok-4.20-multi-agent")) } + +func TestExplicitGrok45DoesNotFollowRuntimeDefault(t *testing.T) { + require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5", "grok-4.6")) + require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID("grok-4.5-latest", "grok-4.6")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok", "grok-4.6")) +} + +func TestBareGrokAliasesFollowGrok46Default(t *testing.T) { + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok")) + require.Equal(t, "grok-4.6", ResolveGrokTextResponsesModelID("grok-latest")) + require.Equal(t, "grok-build-0.1", ResolveGrokTextResponsesModelID("grok-build-latest")) +} diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 90036ff8f2..c7b1577be4 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -350,14 +350,14 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { t.Cleanup(func() { SetRuntimeModelMappingOptions(original) }) SetRuntimeModelMappingOptions(ModelMappingOptions{}) mapping := DefaultModelMapping() - require.Equal(t, "grok-4.5", mapping["grok"]) - require.Equal(t, "grok-4.5", mapping["grok-latest"]) + require.Equal(t, "grok-4.6", mapping["grok"]) + require.Equal(t, "grok-4.6", mapping["grok-latest"]) require.Equal(t, "grok-4.6", mapping["grok-4.6"]) require.Equal(t, "grok-4.6", mapping["grok-4.6-latest"]) require.Equal(t, "grok-4.5", mapping["grok-4.5"]) require.Equal(t, "grok-4.5", mapping["grok-4.5-latest"]) require.Equal(t, "grok-build-0.1", mapping["grok-build"]) - require.Equal(t, "grok-4.5", mapping["grok-build-latest"]) + require.Equal(t, "grok-build-0.1", mapping["grok-build-latest"]) require.Equal(t, "grok-composer-2.5-fast", mapping["grok-composer"]) require.Equal(t, "grok-composer-2.5-fast", mapping["composer-2.5"]) require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"]) @@ -368,7 +368,7 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"]) require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"]) require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"]) - require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"]) + require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"]) require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"]) _, hasGPT := mapping["gpt-*"] require.False(t, hasGPT, "cross-client wildcards must be opt-in") diff --git a/backend/internal/pkg/xai/usage.go b/backend/internal/pkg/xai/usage.go new file mode 100644 index 0000000000..77aa382900 --- /dev/null +++ b/backend/internal/pkg/xai/usage.go @@ -0,0 +1,25 @@ +package xai + +// IncludeIndependentReasoningTokens adds reasoning tokens to billed output +// only when total_tokens proves they are not already folded into output. +// +// Official xAI Chat Completions example: prompt=32, completion=9, +// reasoning=94, total=135. Responses example: input=32, output=9, +// reasoning=110, total=151. OpenAI's canonical completion_tokens already +// includes reasoning and total equals input+output. +func IncludeIndependentReasoningTokens(input, output, total, reasoning int64) int64 { + if input < 0 || output < 0 || reasoning <= 0 || total <= 0 { + return output + } + if total == input+output { + return output + } + gap := total - input - output + if gap <= 0 { + return output + } + if reasoning < gap { + gap = reasoning + } + return output + gap +} diff --git a/backend/internal/pkg/xai/usage_test.go b/backend/internal/pkg/xai/usage_test.go new file mode 100644 index 0000000000..79b8f8639c --- /dev/null +++ b/backend/internal/pkg/xai/usage_test.go @@ -0,0 +1,30 @@ +package xai + +import "testing" + +func TestIncludeIndependentReasoningTokens(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input, output, total, reason int64 + want int64 + }{ + {name: "xAI chat example", input: 32, output: 9, total: 135, reason: 94, want: 103}, + {name: "xAI responses example", input: 32, output: 9, total: 151, reason: 110, want: 119}, + {name: "OpenAI inclusive output", input: 32, output: 103, total: 135, reason: 94, want: 103}, + {name: "gap smaller than reasoning", input: 10, output: 20, total: 33, reason: 5, want: 23}, + {name: "absent total", input: 10, output: 20, total: 0, reason: 5, want: 20}, + {name: "total already complete", input: 10, output: 20, total: 30, reason: 5, want: 20}, + {name: "inconsistent undershoot", input: 10, output: 20, total: 25, reason: 5, want: 20}, + {name: "zero reasoning", input: 10, output: 20, total: 35, reason: 0, want: 20}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := IncludeIndependentReasoningTokens(tt.input, tt.output, tt.total, tt.reason) + if got != tt.want { + t.Fatalf("got %d, want %d", got, tt.want) + } + }) + } +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 010442eb6b..664a845460 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -71,6 +71,39 @@ var schedulerNeutralExtraKeys = map[string]struct{}{ const postgresParameterBatchSize = 50000 +const codexFingerprintSeedCanonicalPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +const codexFingerprintNilSeed = "00000000-0000-0000-0000-000000000000" + +func codexFingerprintSeedValidSQL(extraExpr string) string { + value := "(" + extraExpr + " ->> 'codex_fingerprint_seed')" + return "(" + value + " ~ '" + codexFingerprintSeedCanonicalPattern + "' AND " + value + " <> '" + codexFingerprintNilSeed + "')" +} + +func ensureCodexFingerprintSeedSQL(extraExpr string) string { + return "CASE WHEN platform = 'openai' AND type = 'oauth' THEN " + + "jsonb_set(" + extraExpr + ", '{codex_fingerprint_seed}', " + + "CASE WHEN " + codexFingerprintSeedValidSQL("extra") + + " THEN to_jsonb(extra ->> 'codex_fingerprint_seed') ELSE to_jsonb(gen_random_uuid()::text) END, true) " + + "ELSE " + extraExpr + " END" +} + +func stripCodexFingerprintSeedFromExtraUpdate(extra map[string]any) map[string]any { + if extra == nil { + return nil + } + if _, exists := extra["codex_fingerprint_seed"]; !exists { + return extra + } + stripped := make(map[string]any, len(extra)-1) + for key, value := range extra { + if key == "codex_fingerprint_seed" { + continue + } + stripped[key] = value + } + return stripped +} + // NewAccountRepository 创建账户仓储实例。 // 这是对外暴露的构造函数,返回接口类型以便于依赖注入。 func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AccountRepository { @@ -2520,6 +2553,7 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti } func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { + updates = stripCodexFingerprintSeedFromExtraUpdate(updates) if len(updates) == 0 { return nil } @@ -2552,6 +2586,9 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m if clearProbeSnapshot { extraExpression = "(" + extraExpression + ") - 'upstream_billing_probe'" } + if service.ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates) { + extraExpression = ensureCodexFingerprintSeedSQL(extraExpression) + } result, err := client.ExecContext( ctx, "UPDATE accounts SET extra = "+extraExpression+", updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL", @@ -2793,6 +2830,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates if len(ids) == 0 { return 0, nil } + updates.Extra = stripCodexFingerprintSeedFromExtraUpdate(updates.Extra) setClauses := make([]string, 0, 8) args := make([]any, 0, 8) @@ -2880,7 +2918,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates " AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")") } - if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" { + if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed { extraExpression := "COALESCE(extra, '{}'::jsonb)" if len(updates.Extra) > 0 { payload, err := json.Marshal(updates.Extra) @@ -2919,6 +2957,9 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates } else if snapshotIdentityChanged != "" { extraExpression = "CASE WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot' ELSE " + extraExpression + " END" } + if updates.EnsureCodexFingerprintSeed { + extraExpression = ensureCodexFingerprintSeedSQL(extraExpression) + } setClauses = append(setClauses, "extra = "+extraExpression) } diff --git a/backend/internal/repository/account_repo_codex_fingerprint_seed_test.go b/backend/internal/repository/account_repo_codex_fingerprint_seed_test.go new file mode 100644 index 0000000000..1ad1723748 --- /dev/null +++ b/backend/internal/repository/account_repo_codex_fingerprint_seed_test.go @@ -0,0 +1,123 @@ +package repository + +import ( + "context" + "errors" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" +) + +func TestBulkUpdateEnsuresCodexFingerprintSeedWithPerRowSQL(t *testing.T) { + exec := &recordingSQLExecutor{result: rowsAffectedResult(0)} + repo := newAccountRepositoryWithSQL(nil, exec, nil) + + _, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{ + Extra: map[string]any{ + "codex_fingerprint_mode": "session", + "codex_fingerprint_seed": "22222222-2222-4222-8222-222222222222", + }, + EnsureCodexFingerprintSeed: true, + }) + + require.NoError(t, err) + require.NotEmpty(t, exec.execQueries) + query := normalizeSQLWhitespace(exec.execQueries[0]) + require.Contains(t, query, "jsonb_set") + require.Contains(t, query, "gen_random_uuid()::text") + require.Contains(t, query, "platform = 'openai' AND type = 'oauth'") + require.Contains(t, query, "to_jsonb(extra ->> 'codex_fingerprint_seed')") + require.Contains(t, query, codexFingerprintSeedCanonicalPattern) + require.NotContains(t, query, "22222222-2222-4222-8222-222222222222") + require.NotEmpty(t, exec.execArgs) + payload, ok := exec.execArgs[0][0].([]byte) + require.True(t, ok) + require.Equal(t, `{"codex_fingerprint_mode":"session"}`, string(payload)) +} + +func TestUpdateExtraEnsuresCodexFingerprintSeedAtomicallyWhenEnabling(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts SET extra = .*jsonb_set.*gen_random_uuid\(\)::text.*WHERE id = \$2 AND deleted_at IS NULL`). + WithArgs(`{"codex_fingerprint_mode":"device"}`, int64(27)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(27), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, db, nil) + + err = repo.UpdateExtra(context.Background(), 27, map[string]any{ + "codex_fingerprint_mode": "device", + "codex_fingerprint_seed": "22222222-2222-4222-8222-222222222222", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBulkUpdateCodexFingerprintSeedRollsBackWhenUpdateFails(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts SET extra = .*gen_random_uuid\(\)::text.*WHERE id = ANY\(\$2\)`). + WithArgs(sqlmock.AnyArg(), `{27,28}`). + WillReturnError(errors.New("update failed")) + mock.ExpectRollback() + + repo := newAccountRepositoryWithSQL(client, db, nil) + rows, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{ + Extra: map[string]any{ + "codex_fingerprint_mode": "session", + }, + EnsureCodexFingerprintSeed: true, + }) + + require.EqualError(t, err, "update failed") + require.Zero(t, rows) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBulkUpdateCodexFingerprintSeedRollsBackWhenOutboxFails(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts SET extra = .*gen_random_uuid\(\)::text.*WHERE id = ANY\(\$2\)`). + WithArgs(sqlmock.AnyArg(), `{27,28}`). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WillReturnError(errors.New("outbox failed")) + mock.ExpectRollback() + + repo := newAccountRepositoryWithSQL(client, db, nil) + rows, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{ + Extra: map[string]any{ + "codex_fingerprint_mode": "full", + }, + EnsureCodexFingerprintSeed: true, + }) + + require.EqualError(t, err, "outbox failed") + require.Zero(t, rows) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/channel_monitor_quota_integration_test.go b/backend/internal/repository/channel_monitor_quota_integration_test.go new file mode 100644 index 0000000000..2ffc2ae692 --- /dev/null +++ b/backend/internal/repository/channel_monitor_quota_integration_test.go @@ -0,0 +1,155 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 配额模式 repo 层集成测试: +// - Create/GetByID/Update 的 check_mode/account_id 往返 +// - InsertHistoryBatch → ListHistory / ListLatestForMonitorIDs 的 quota JSONB 回读 +// (裸 SQL 列 + scanMonitorQuota),以及探活模式旧行 quota=NULL 的兼容 +// +// 注意 channelMonitorRepository 的 GetByID/裸 SQL 走全局 client(不识别 tx ctx), +// 因此本文件用 integrationEntClient 直连 + t.Cleanup 显式清理,不走 testEntTx 回滚。 + +func TestChannelMonitorQuotaModeRoundTrip(t *testing.T) { + ctx := context.Background() + repo := NewChannelMonitorRepository(integrationEntClient, integrationDB) + + account := mustCreateAccount(t, integrationEntClient, &service.Account{ + Name: "quota-linked-kimi", Platform: domain.PlatformKimi, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-kimi", "account_mode": service.AccountModeCoding}, + }) + t.Cleanup(func() { + _ = integrationEntClient.Account.DeleteOneID(account.ID).Exec(ctx) + }) + + created := &service.ChannelMonitor{ + Name: "kimi-quota-roundtrip", + Provider: service.MonitorProviderKimi, + APIMode: service.MonitorAPIModeChatCompletions, + Endpoint: "", + APIKey: "encrypted-empty", + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: service.MonitorCheckModeQuota, + AccountID: &account.ID, + BodyOverrideMode: service.MonitorBodyOverrideModeOff, + } + require.NoError(t, repo.Create(ctx, created)) + t.Cleanup(func() { + _ = repo.Delete(ctx, created.ID) + }) + + loaded, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, service.MonitorCheckModeQuota, loaded.CheckMode) + require.NotNil(t, loaded.AccountID) + require.Equal(t, account.ID, *loaded.AccountID) + + // Update:切换模式并清空关联账号(probe 化)。 + loaded.CheckMode = service.MonitorCheckModeProbe + loaded.AccountID = nil + loaded.Endpoint = "https://api.moonshot.cn" + require.NoError(t, repo.Update(ctx, loaded)) + + reloaded, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, service.MonitorCheckModeProbe, reloaded.CheckMode) + require.Nil(t, reloaded.AccountID) + + // 重新绑定账号。 + reloaded.CheckMode = service.MonitorCheckModeQuotaProbe + reloaded.AccountID = &account.ID + require.NoError(t, repo.Update(ctx, reloaded)) + final, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, service.MonitorCheckModeQuotaProbe, final.CheckMode) + require.NotNil(t, final.AccountID) +} + +func TestChannelMonitorHistoryQuotaRoundTrip(t *testing.T) { + ctx := context.Background() + repo := NewChannelMonitorRepository(integrationEntClient, integrationDB) + + monitor := &service.ChannelMonitor{ + Name: "quota-history-roundtrip", + Provider: service.MonitorProviderOpenAI, + APIMode: service.MonitorAPIModeChatCompletions, + Endpoint: "https://api.openai.com", + APIKey: "encrypted", + PrimaryModel: "gpt-test", + ExtraModels: []string{"gpt-extra"}, + Enabled: true, + IntervalSeconds: 60, + BodyOverrideMode: service.MonitorBodyOverrideModeOff, + } + require.NoError(t, repo.Create(ctx, monitor)) + t.Cleanup(func() { + _ = repo.Delete(ctx, monitor.ID) // histories 级联删除 + }) + + now := time.Now().UTC() + rows := []*service.ChannelMonitorHistoryRow{ + { + MonitorID: monitor.ID, Model: "gpt-test", Status: service.MonitorStatusOperational, + Message: "ok", CheckedAt: now, + Quota: &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: true, + PlanLevel: "PRO", + Tiers: []domain.MonitorQuotaTier{ + {Window: "5h", UsedPercent: 42.5, Used: 17, Limit: 40, ResetAt: now.Add(time.Hour).Format(time.RFC3339)}, + }, + FetchedAt: now, + }, + }, + { + // 探活模式旧行:无 quota(NULL 兼容)。 + MonitorID: monitor.ID, Model: "gpt-extra", Status: service.MonitorStatusOperational, + Message: "ok", CheckedAt: now, + }, + } + require.NoError(t, repo.InsertHistoryBatch(ctx, rows)) + + history, err := repo.ListHistory(ctx, monitor.ID, "", 10) + require.NoError(t, err) + require.Len(t, history, 2) + + byModel := map[string]*service.ChannelMonitorHistoryEntry{} + for _, entry := range history { + byModel[entry.Model] = entry + } + withQuota := byModel["gpt-test"] + require.NotNil(t, withQuota.Quota) + require.True(t, withQuota.Quota.Success) + require.Equal(t, "usage", withQuota.Quota.Source) + require.Equal(t, "PRO", withQuota.Quota.PlanLevel) + require.Len(t, withQuota.Quota.Tiers, 1) + require.Equal(t, "5h", withQuota.Quota.Tiers[0].Window) + require.InDelta(t, 42.5, withQuota.Quota.Tiers[0].UsedPercent, 0.001) + require.Nil(t, byModel["gpt-extra"].Quota, "probe rows must read back as NULL quota") + + // 用户视图聚合:主模型最近一行带快照。 + latest, err := repo.ListLatestForMonitorIDs(ctx, []int64{monitor.ID}) + require.NoError(t, err) + primaryRows := latest[monitor.ID] + require.NotEmpty(t, primaryRows) + var primaryQuota *domain.MonitorQuotaSnapshot + for _, row := range primaryRows { + if row.Model == "gpt-test" { + primaryQuota = row.Quota + } + } + require.NotNil(t, primaryQuota, "ListLatestForMonitorIDs must surface quota for the primary model") + require.True(t, primaryQuota.Success) +} diff --git a/backend/internal/repository/channel_monitor_repo.go b/backend/internal/repository/channel_monitor_repo.go index aa8b12ff8b..c4586bc38c 100644 --- a/backend/internal/repository/channel_monitor_repo.go +++ b/backend/internal/repository/channel_monitor_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -10,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/lib/pq" @@ -51,10 +53,14 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe SetJitterSeconds(m.JitterSeconds). SetCreatedBy(m.CreatedBy). SetExtraHeaders(channelMonitorHeadersForPersistence(m)). - SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) + SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)). + SetCheckMode(defaultCheckModeRepo(m.CheckMode)) if m.TemplateID != nil { builder = builder.SetTemplateID(*m.TemplateID) } + if m.AccountID != nil { + builder = builder.SetAccountID(*m.AccountID) + } if m.BodyOverride != nil { builder = builder.SetBodyOverride(m.BodyOverride) } @@ -118,12 +124,18 @@ func (r *channelMonitorRepository) Update(ctx context.Context, m *service.Channe SetIntervalSeconds(m.IntervalSeconds). SetJitterSeconds(m.JitterSeconds). SetExtraHeaders(channelMonitorHeadersForPersistence(m)). - SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) + SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)). + SetCheckMode(defaultCheckModeRepo(m.CheckMode)) if m.TemplateID != nil { updater = updater.SetTemplateID(*m.TemplateID) } else { updater = updater.ClearTemplateID() } + if m.AccountID != nil { + updater = updater.SetAccountID(*m.AccountID) + } else { + updater = updater.ClearAccountID() + } if m.BodyOverride != nil { updater = updater.SetBodyOverride(m.BodyOverride) } else { @@ -237,6 +249,9 @@ func (r *channelMonitorRepository) InsertHistoryBatch(ctx context.Context, rows if row.PingLatencyMs != nil { c = c.SetPingLatencyMs(*row.PingLatencyMs) } + if row.Quota != nil { + c = c.SetQuota(row.Quota) + } bulk = append(bulk, c) } if _, err := client.ChannelMonitorHistory.CreateBulk(bulk...).Save(ctx); err != nil { @@ -276,6 +291,7 @@ func (r *channelMonitorRepository) ListHistory(ctx context.Context, monitorID in PingLatencyMs: row.PingLatencyMs, Message: row.Message, CheckedAt: row.CheckedAt, + Quota: row.Quota, } out = append(out, entry) } @@ -324,6 +340,20 @@ func assignNullInt(dst **int, n sql.NullInt64) { *dst = &v } +// scanMonitorQuota 把裸 SQL 读出的 JSONB quota 列解包为配额快照。 +// NULL(探活模式旧行)返回 nil;解析失败也返回 nil 并由调用方日志感知, +// 不阻断列表渲染(与聚合层"失败仅日志"的原则一致)。 +func scanMonitorQuota(data []byte) *domain.MonitorQuotaSnapshot { + if len(data) == 0 { + return nil + } + snapshot := &domain.MonitorQuotaSnapshot{} + if err := json.Unmarshal(data, snapshot); err != nil { + return nil + } + return snapshot +} + // ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。 // "可用" = status IN (operational, degraded)。 // @@ -396,7 +426,7 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, } const q = ` SELECT DISTINCT ON (monitor_id, model) - monitor_id, model, status, latency_ms, ping_latency_ms, checked_at + monitor_id, model, status, latency_ms, ping_latency_ms, checked_at, quota FROM channel_monitor_histories WHERE monitor_id = ANY($1) ORDER BY monitor_id, model, checked_at DESC @@ -411,11 +441,13 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, var monitorID int64 l := &service.ChannelMonitorLatest{} var latency, ping sql.NullInt64 - if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil { + var quota []byte + if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt, "a); err != nil { return nil, fmt.Errorf("scan latest batch row: %w", err) } assignNullInt(&l.LatencyMs, latency) assignNullInt(&l.PingLatencyMs, ping) + l.Quota = scanMonitorQuota(quota) out[monitorID] = append(out[monitorID], l) } if err := rows.Err(); err != nil { @@ -757,12 +789,17 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor { ExtraHeaders: headers, BodyOverrideMode: row.BodyOverrideMode, BodyOverride: row.BodyOverride, + CheckMode: defaultCheckModeRepo(row.CheckMode), DuplicateOperationID: duplicateOperationID, } if row.TemplateID != nil { id := *row.TemplateID out.TemplateID = &id } + if row.AccountID != nil { + id := *row.AccountID + out.AccountID = &id + } return out } @@ -807,6 +844,14 @@ func defaultAPIModeRepo(apiMode string) string { return apiMode } +// defaultCheckModeRepo 空串归一为 probe(存量行有列默认值,这里兜底防御)。 +func defaultCheckModeRepo(checkMode string) string { + if checkMode == "" { + return "probe" + } + return checkMode +} + func emptySliceIfNil(in []string) []string { if in == nil { return []string{} diff --git a/backend/internal/repository/channel_monitor_v2_aggregation.go b/backend/internal/repository/channel_monitor_v2_aggregation.go index 6b91a8132b..9bec5877b5 100644 --- a/backend/internal/repository/channel_monitor_v2_aggregation.go +++ b/backend/internal/repository/channel_monitor_v2_aggregation.go @@ -247,18 +247,28 @@ WITH dedup AS ( FROM ops_error_logs WHERE created_at >= $1 AND created_at < $2 AND NULLIF(request_id, '') IS NOT NULL ) - SELECT DISTINCT ON (COALESCE(NULLIF(request_id, ''), 'error:' || id::text)) - date_trunc('minute', created_at) AS bucket_start, - lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) AS platform, - COALESCE(group_id, 0) AS group_id, - COALESCE(NULLIF(TRIM(requested_model), ''), NULLIF(TRIM(model), ''), 'unknown') AS model, - user_id, error_type, error_owner, COALESCE(status_code, 0) AS status_code, - COALESCE(upstream_status_code, 0) AS upstream_status_code, - lower(CONCAT_WS(' ', error_type, error_source, error_message, upstream_error_message, upstream_error_detail, error_body)) AS text, - (CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) > 0 ELSE FALSE END - OR error_owner = 'provider' OR upstream_status_code IS NOT NULL) AS upstream_affected, - CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) ELSE 0 END AS upstream_attempts + SELECT DISTINCT ON (COALESCE(NULLIF(current_error.request_id, ''), 'error:' || current_error.id::text)) + date_trunc('minute', current_error.created_at) AS bucket_start, + -- Composite groups are a routing layer: resolve the concrete account + -- platform (mirrors usageLogEffectivePlatformExpr on the usage side) so + -- error facts share the usage facts' platform key. Without this, composite + -- group errors aggregate under platform 'composite', which is never an + -- enabled config platform, and are filtered out of every monitor v2 query. + lower(CASE + WHEN g.platform = 'composite' THEN COALESCE(NULLIF(TRIM(a.platform), ''), NULLIF(NULLIF(lower(TRIM(current_error.platform)), ''), 'composite'), 'unknown') + ELSE COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown') + END) AS platform, + COALESCE(current_error.group_id, 0) AS group_id, + COALESCE(NULLIF(TRIM(current_error.requested_model), ''), NULLIF(TRIM(current_error.model), ''), 'unknown') AS model, + current_error.user_id, current_error.error_type, current_error.error_owner, COALESCE(current_error.status_code, 0) AS status_code, + COALESCE(current_error.upstream_status_code, 0) AS upstream_status_code, + lower(CONCAT_WS(' ', current_error.error_type, current_error.error_source, current_error.error_message, current_error.upstream_error_message, current_error.upstream_error_detail, current_error.error_body)) AS text, + (CASE WHEN jsonb_typeof(current_error.upstream_errors) = 'array' THEN jsonb_array_length(current_error.upstream_errors) > 0 ELSE FALSE END + OR current_error.error_owner = 'provider' OR current_error.upstream_status_code IS NOT NULL) AS upstream_affected, + CASE WHEN jsonb_typeof(current_error.upstream_errors) = 'array' THEN jsonb_array_length(current_error.upstream_errors) ELSE 0 END AS upstream_attempts FROM ops_error_logs current_error + LEFT JOIN groups g ON g.id = current_error.group_id + LEFT JOIN accounts a ON a.id = current_error.account_id WHERE ( (NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2) OR ( @@ -269,7 +279,7 @@ WITH dedup AS ( ) AND NOT current_error.is_count_tokens AND (COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy') - ORDER BY COALESCE(NULLIF(request_id, ''), 'error:' || id::text), created_at DESC, id DESC + ORDER BY COALESCE(NULLIF(current_error.request_id, ''), 'error:' || current_error.id::text), current_error.created_at DESC, current_error.id DESC ), classified AS ( SELECT *, CASE -- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles. diff --git a/backend/internal/repository/channel_monitor_v2_repo_test.go b/backend/internal/repository/channel_monitor_v2_repo_test.go index 14b54eb1fc..82d5e61130 100644 --- a/backend/internal/repository/channel_monitor_v2_repo_test.go +++ b/backend/internal/repository/channel_monitor_v2_repo_test.go @@ -101,12 +101,25 @@ func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) require.Contains(t, query, "candidate_ids") require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2") require.Contains(t, query, "upstream_affected_requests") - require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0") + require.Contains(t, query, "jsonb_array_length(current_error.upstream_errors) > 0") // request_id dedup must be time-bounded (no full-history scan). require.Contains(t, query, "interval '90 minutes'") require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'") } +func TestChannelMonitorV2ErrorAggregationResolvesCompositePlatform(t *testing.T) { + query := strings.ToLower(channelMonitorV2ErrorAggregationSQL) + // Composite groups are a routing layer: error facts must resolve the concrete + // account platform (joining groups/accounts) so they aggregate under the same + // platform key as usage facts instead of the never-enabled 'composite' platform. + require.Contains(t, query, "g.platform = 'composite'") + require.Contains(t, query, "left join groups g on g.id = current_error.group_id") + require.Contains(t, query, "left join accounts a on a.id = current_error.account_id") + require.Contains(t, query, "a.platform") + require.Contains(t, query, "nullif(trim(a.platform), '')") + require.NotContains(t, query, "nullif(trim(a.platform))") +} + func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) { for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} { require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)") diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 995621139e..e25642085a 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -51,16 +51,21 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser if err != nil { return fmt.Errorf("marshal models: %w", err) } + timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing) + if err != nil { + return err + } billingMode := pricing.BillingMode if billingMode == "" { billingMode = service.BillingModeToken } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, platform = $10, updated_at = NOW() - WHERE id = $11`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, fast_multiplier = $7, flex_multiplier = $8, image_input_price = $9, image_output_price = $10, per_request_price = $11, time_pricing = $12, platform = $13, updated_at = NOW() + WHERE id = $14`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { return fmt.Errorf("update model pricing: %w", err) @@ -91,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -132,6 +137,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [ rows, err := r.db.QueryContext(ctx, `SELECT id, pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, + input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order, created_at, updated_at FROM channel_pricing_intervals WHERE pricing_id = ANY($1) ORDER BY pricing_id, sort_order, id`, @@ -148,6 +154,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [ if err := rows.Scan( &iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel, &iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice, + &iv.InputMultiplier, &iv.OutputMultiplier, &iv.CacheWriteMultiplier, &iv.CacheReadMultiplier, &iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan interval: %w", err) @@ -169,16 +176,23 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 for rows.Next() { var p service.ChannelModelPricing var modelsJSON []byte + var timePricingJSON []byte if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice, - &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt, + &p.FastMultiplier, &p.FlexMultiplier, + &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) } if err := json.Unmarshal(modelsJSON, &p.Models); err != nil { p.Models = []string{} } + timePricing, err := unmarshalChannelTimePricing(timePricingJSON) + if err != nil { + return nil, nil, err + } + p.TimePricing = timePricing pricingIDs = append(pricingIDs, p.ID) result = append(result, p) } @@ -220,6 +234,10 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C if err != nil { return fmt.Errorf("marshal models: %w", err) } + timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing) + if err != nil { + return err + } billingMode := pricing.BillingMode if billingMode == "" { billingMode = service.BillingModeToken @@ -229,11 +247,12 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { return fmt.Errorf("insert model pricing: %w", err) @@ -249,13 +268,36 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C return nil } +func marshalChannelTimePricing(config *service.ChannelTimePricing) (any, error) { + if config == nil || len(config.Periods) == 0 { + return nil, nil + } + data, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("marshal time pricing: %w", err) + } + return string(data), nil +} + +func unmarshalChannelTimePricing(data []byte) (*service.ChannelTimePricing, error) { + if len(data) == 0 { + return nil, nil + } + var config service.ChannelTimePricing + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("unmarshal time pricing: %w", err) + } + return &config, nil +} + func createIntervalExec(ctx context.Context, exec dbExec, iv *service.PricingInterval) error { return exec.QueryRowContext(ctx, `INSERT INTO channel_pricing_intervals - (pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, per_request_price, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`, + (pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel, iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice, + iv.InputMultiplier, iv.OutputMultiplier, iv.CacheWriteMultiplier, iv.CacheReadMultiplier, iv.PerRequestPrice, iv.SortOrder, ).Scan(&iv.ID, &iv.CreatedAt, &iv.UpdatedAt) } diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go new file mode 100644 index 0000000000..38acdbdc73 --- /dev/null +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -0,0 +1,181 @@ +//go:build unit + +package repository + +import ( + "context" + "regexp" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +var channelModelPricingTimePricingColumns = []string{ + "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", + "cache_write_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price", + "per_request_price", "time_pricing", "created_at", "updated_at", +} + +const channelModelPricingTimePricingJSON = `{"timezone":"Asia/Shanghai","periods":[{"start_time":"09:00","end_time":"12:00","multiplier":2}]}` + +func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sqlmock.Sqlmock) { + t.Helper() + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return &channelRepository{db: db}, mock +} + +func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { + return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( + int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, + time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), + ) +} + +func expectEmptyModelPricingIntervals(mock sqlmock.Sqlmock) { + mock.ExpectQuery(`SELECT id, pricing_id, min_tokens, max_tokens, tier_label`). + WithArgs(sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"})) +} + +func TestChannelModelPricingTimePricingListRoundTrip(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`(?s)SELECT .*per_request_price, time_pricing, created_at, updated_at.*FROM channel_model_pricing.*channel_id = \$1`). + WithArgs(int64(7)). + WillReturnRows(modelPricingTimePricingRow(channelModelPricingTimePricingJSON)) + expectEmptyModelPricingIntervals(mock) + + pricing, err := repo.ListModelPricing(context.Background(), 7) + require.NoError(t, err) + require.Len(t, pricing, 1) + require.NotNil(t, pricing[0].TimePricing) + require.Equal(t, "Asia/Shanghai", pricing[0].TimePricing.Timezone) + require.Len(t, pricing[0].TimePricing.Periods, 1) + require.Equal(t, 2.0, pricing[0].TimePricing.Periods[0].Multiplier) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChannelModelPricingTimePricingListNullAndMalformed(t *testing.T) { + t.Run("SQL NULL maps to nil", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`(?s)SELECT .*per_request_price, time_pricing, created_at, updated_at.*FROM channel_model_pricing.*channel_id = \$1`). + WithArgs(int64(7)). + WillReturnRows(modelPricingTimePricingRow(nil)) + expectEmptyModelPricingIntervals(mock) + + pricing, err := repo.ListModelPricing(context.Background(), 7) + require.NoError(t, err) + require.Len(t, pricing, 1) + require.Nil(t, pricing[0].TimePricing) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("malformed JSON returns repository error", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(`(?s)SELECT .*per_request_price, time_pricing, created_at, updated_at.*FROM channel_model_pricing.*channel_id = \$1`). + WithArgs(int64(7)). + WillReturnRows(modelPricingTimePricingRow(`{"timezone":`)) + + _, err := repo.ListModelPricing(context.Background(), 7) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "unmarshal time pricing"), "unexpected error: %v", err) + require.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { + pricing := &service.ChannelModelPricing{ + ID: 11, + ChannelID: 7, + Platform: "openai", + Models: []string{"gpt-5"}, + TimePricing: &service.ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []service.ChannelTimePricingPeriod{{ + StartTime: "09:00", EndTime: "12:00", Multiplier: 2, + }}, + }, + } + + t.Run("create writes JSON", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + WithArgs( + int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + ). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) + + require.NoError(t, repo.CreateModelPricing(context.Background(), pricing)) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("update writes JSON and entry ID", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`). + WithArgs( + []byte(`["gpt-5"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + ). + WillReturnResult(sqlmock.NewResult(0, 1)) + + require.NoError(t, repo.UpdateModelPricing(context.Background(), pricing)) + require.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *testing.T) { + tests := []struct { + name string + timePricing *service.ChannelTimePricing + }{ + {name: "nil", timePricing: nil}, + {name: "empty periods", timePricing: &service.ChannelTimePricing{Timezone: "Asia/Shanghai"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newPricing := func() *service.ChannelModelPricing { + return &service.ChannelModelPricing{ + ID: 11, + ChannelID: 7, + Platform: "openai", + Models: []string{"gpt-5"}, + TimePricing: tt.timePricing, + } + } + + t.Run("create writes SQL NULL", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + WithArgs( + int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) + + require.NoError(t, repo.CreateModelPricing(context.Background(), newPricing())) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("update writes SQL NULL", func(t *testing.T) { + repo, mock := newChannelModelPricingTimePricingRepo(t) + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`). + WithArgs( + []byte(`["gpt-5"]`), service.BillingModeToken, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + ). + WillReturnResult(sqlmock.NewResult(0, 1)) + + require.NoError(t, repo.UpdateModelPricing(context.Background(), newPricing())) + require.NoError(t, mock.ExpectationsWereMet()) + }) + }) + } +} diff --git a/backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go b/backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go new file mode 100644 index 0000000000..70ae61eb8e --- /dev/null +++ b/backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go @@ -0,0 +1,150 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func requireCanonicalUUIDString(t *testing.T, value string) { + t.Helper() + parsed, err := uuid.Parse(value) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, parsed) + require.Equal(t, parsed.String(), value) +} + +func TestMigration225BackfillsOnlyEnabledOpenAIOAuthMissingOrMalformedSeeds(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + migrationSQL, err := dbmigrations.FS.ReadFile("225_backfill_codex_fingerprint_seed.sql") + require.NoError(t, err) + + var missingID, blankID, malformedID, validID, offID, apiKeyID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-225-missing', 'openai', 'oauth', '{"codex_fingerprint_mode":"session"}'::jsonb) +RETURNING id +`).Scan(&missingID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-225-blank', 'openai', 'oauth', '{"codex_fingerprint_mode":"device","codex_fingerprint_seed":""}'::jsonb) +RETURNING id +`).Scan(&blankID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-225-malformed', 'openai', 'oauth', '{"codex_fingerprint_mode":"full","codex_fingerprint_seed":"BAD"}'::jsonb) +RETURNING id +`).Scan(&malformedID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-225-valid', 'openai', 'oauth', '{"codex_fingerprint_mode":"session","codex_fingerprint_seed":"11111111-1111-4111-8111-111111111111"}'::jsonb) +RETURNING id +`).Scan(&validID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-225-off', 'openai', 'oauth', '{"codex_fingerprint_mode":"off"}'::jsonb) +RETURNING id +`).Scan(&offID)) + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-225-apikey', 'openai', 'apikey', '{"codex_fingerprint_mode":"session"}'::jsonb) +RETURNING id +`).Scan(&apiKeyID)) + + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + seedsAfterFirst := map[int64]string{} + for _, id := range []int64{missingID, blankID, malformedID, validID} { + var seed string + require.NoError(t, tx.QueryRowContext(ctx, `SELECT extra->>'codex_fingerprint_seed' FROM accounts WHERE id = $1`, id).Scan(&seed)) + requireCanonicalUUIDString(t, seed) + seedsAfterFirst[id] = seed + } + require.Equal(t, "11111111-1111-4111-8111-111111111111", seedsAfterFirst[validID]) + + for _, id := range []int64{offID, apiKeyID} { + var hasSeed bool + require.NoError(t, tx.QueryRowContext(ctx, `SELECT extra ? 'codex_fingerprint_seed' FROM accounts WHERE id = $1`, id).Scan(&hasSeed)) + require.False(t, hasSeed) + } + + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + for id, want := range seedsAfterFirst { + var got string + require.NoError(t, tx.QueryRowContext(ctx, `SELECT extra->>'codex_fingerprint_seed' FROM accounts WHERE id = $1`, id).Scan(&got)) + require.Equal(t, want, got) + } +} + +func TestBulkUpdateGeneratesDistinctStableCodexFingerprintSeedsPerEligibleRow(t *testing.T) { + ctx := context.Background() + testName := "bulk-codex-seed-" + uuid.NewString() + type fixture struct { + name string + accountType string + extra string + } + fixtures := []fixture{ + {name: testName + "-missing-a", accountType: service.AccountTypeOAuth, extra: `{}`}, + {name: testName + "-missing-b", accountType: service.AccountTypeOAuth, extra: `{"codex_fingerprint_seed":"BAD"}`}, + {name: testName + "-valid", accountType: service.AccountTypeOAuth, extra: `{"codex_fingerprint_seed":"11111111-1111-4111-8111-111111111111"}`}, + {name: testName + "-apikey", accountType: service.AccountTypeAPIKey, extra: `{}`}, + } + + ids := make([]int64, 0, len(fixtures)) + for _, f := range fixtures { + var id int64 + require.NoError(t, integrationDB.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ($1, 'openai', $2, $3::jsonb) +RETURNING id +`, f.name, f.accountType, f.extra).Scan(&id)) + ids = append(ids, id) + } + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM scheduler_outbox WHERE account_id = ANY($1)`, pq.Array(ids)) + _, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM accounts WHERE id = ANY($1)`, pq.Array(ids)) + }) + + repo := newAccountRepositoryWithSQL(testEntClient(t), integrationDB, nil) + updates := service.AccountBulkUpdate{ + Extra: map[string]any{ + "codex_fingerprint_mode": "session", + }, + EnsureCodexFingerprintSeed: true, + } + rows, err := repo.BulkUpdate(ctx, ids, updates) + require.NoError(t, err) + require.Equal(t, int64(len(ids)), rows) + + readSeed := func(id int64) string { + t.Helper() + var seed string + require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT COALESCE(extra->>'codex_fingerprint_seed', '') FROM accounts WHERE id = $1`, id).Scan(&seed)) + return seed + } + firstSeeds := []string{readSeed(ids[0]), readSeed(ids[1]), readSeed(ids[2]), readSeed(ids[3])} + requireCanonicalUUIDString(t, firstSeeds[0]) + requireCanonicalUUIDString(t, firstSeeds[1]) + require.NotEqual(t, firstSeeds[0], firstSeeds[1], "gen_random_uuid must be evaluated per eligible row") + require.Equal(t, "11111111-1111-4111-8111-111111111111", firstSeeds[2]) + require.Empty(t, firstSeeds[3], "API-key accounts must not receive a Codex fingerprint seed") + + rows, err = repo.BulkUpdate(ctx, ids, updates) + require.NoError(t, err) + require.Equal(t, int64(len(ids)), rows) + for i, want := range firstSeeds { + require.Equal(t, want, readSeed(ids[i]), "retry must not rotate an existing valid seed") + } +} diff --git a/backend/internal/repository/custom_group_usage_rollup_repo.go b/backend/internal/repository/custom_group_usage_rollup_repo.go new file mode 100644 index 0000000000..ac30edfc19 --- /dev/null +++ b/backend/internal/repository/custom_group_usage_rollup_repo.go @@ -0,0 +1,259 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Context, todayStart time.Time) (results []usagestats.GroupUsageSummary, err error) { + todayStart = service.GroupUsageTodayStart(todayStart) + yesterdayStart := service.GroupUsageYesterdayStart(todayStart) + timezoneName := service.GroupUsageTimezoneName() + todayDate := service.GroupUsageDate(todayStart) + yesterdayDate := service.GroupUsageDate(yesterdayStart) + + const query = ` + WITH state_values AS ( + SELECT + COUNT(*) = 1 + AND MAX(timezone_name) = $3 + AND MAX(closed_before) <= $4::date AS valid, + MAX(closed_before) AS closed_before, + MAX(retained_from) AS retained_from + FROM usage_group_rollup_state + WHERE id = 1 + ), + state AS ( + SELECT + CASE WHEN valid THEN closed_before ELSE DATE '1970-01-01' END AS closed_before, + CASE WHEN valid THEN retained_from ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00' END AS retained_from, + CASE + WHEN valid THEN closed_before::timestamp AT TIME ZONE $3::text + ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00' + END AS tail_start, + valid + FROM state_values + ), + historical AS ( + SELECT + rollup.group_id, + COALESCE(SUM(rollup.actual_cost), 0) AS actual_cost, + COALESCE(SUM(rollup.actual_cost) FILTER ( + WHERE rollup.bucket_date = $5::date + ), 0) AS yesterday_cost + FROM usage_group_daily_rollups rollup + CROSS JOIN state + WHERE state.valid + AND rollup.bucket_date >= (state.retained_from AT TIME ZONE $3::text)::date + AND rollup.bucket_date < state.closed_before + GROUP BY rollup.group_id + ), + tail AS ( + SELECT + ul.group_id, + COALESCE(SUM(ul.actual_cost), 0) AS actual_cost, + COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $1), 0) AS today_cost, + COALESCE(SUM(ul.actual_cost) FILTER ( + WHERE ul.created_at >= $2 + AND ul.created_at < $1 + ), 0) AS yesterday_cost + FROM usage_logs ul + CROSS JOIN state + WHERE ul.created_at >= state.tail_start + GROUP BY ul.group_id + ) + SELECT + g.id AS group_id, + COALESCE(historical.actual_cost, 0) + COALESCE(tail.actual_cost, 0) AS total_cost, + COALESCE(tail.today_cost, 0) AS today_cost, + COALESCE(historical.yesterday_cost, 0) + COALESCE(tail.yesterday_cost, 0) AS yesterday_cost + FROM groups g + LEFT JOIN historical ON historical.group_id = g.id + LEFT JOIN tail ON tail.group_id = g.id + ORDER BY g.id + ` + + rows, err := r.sql.QueryContext( + ctx, + query, + todayStart, + yesterdayStart, + timezoneName, + todayDate, + yesterdayDate, + ) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]usagestats.GroupUsageSummary, 0) + for rows.Next() { + var row usagestats.GroupUsageSummary + if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost, &row.YesterdayCost); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// SyncGroupUsageRollups 将服务端配置时区今日以前的用量发布为分组日桶。 +func (r *dashboardAggregationRepository) SyncGroupUsageRollups(ctx context.Context, todayStart time.Time) error { + if r == nil || r.sql == nil { + return nil + } + todayStart = service.GroupUsageTodayStart(todayStart) + if db, ok := r.sql.(*sql.DB); ok { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + txRepo := newDashboardAggregationRepositoryWithSQL(tx) + if err := txRepo.syncGroupUsageRollupsInTx(ctx, todayStart); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() + } + return r.syncGroupUsageRollupsInTx(ctx, todayStart) +} + +func (r *dashboardAggregationRepository) syncGroupUsageRollupsInTx(ctx context.Context, todayStart time.Time) error { + var closedBefore string + var previousRetainedFrom time.Time + var stateTimezoneName string + if err := scanSingleRow(ctx, r.sql, ` + SELECT closed_before::text, retained_from, timezone_name + FROM usage_group_rollup_state + WHERE id = 1 + FOR UPDATE + `, nil, &closedBefore, &previousRetainedFrom, &stateTimezoneName); err != nil { + return fmt.Errorf("读取分组用量汇总水位: %w", err) + } + + todayDate := service.GroupUsageDate(todayStart) + timezoneName := service.GroupUsageTimezoneName() + timezoneChanged := stateTimezoneName != timezoneName + var closedTime time.Time + if !timezoneChanged { + var err error + closedTime, err = service.ParseGroupUsageDate(closedBefore) + if err != nil { + return fmt.Errorf("解析分组用量汇总水位 %q: %w", closedBefore, err) + } + todayDateTime, err := service.ParseGroupUsageDate(todayDate) + if err != nil { + return err + } + if closedTime.After(todayDateTime) { + return fmt.Errorf("分组用量汇总水位位于未来: %s", closedBefore) + } + if closedBefore == todayDate { + return nil + } + } + + var earliest sql.NullTime + if err := scanSingleRow(ctx, r.sql, "SELECT MIN(created_at) FROM usage_logs", nil, &earliest); err != nil { + return fmt.Errorf("读取最早用量记录: %w", err) + } + retainedFrom := todayStart + if earliest.Valid { + retainedFrom = earliest.Time.UTC() + } + retainedDate := service.GroupUsageDate(retainedFrom) + retainedDateTime, err := service.ParseGroupUsageDate(retainedDate) + if err != nil { + return err + } + rebuildStartDate := retainedDate + if !timezoneChanged && closedTime.After(retainedDateTime) { + rebuildStartDate = closedBefore + } + rebuildStart, err := service.ParseGroupUsageDate(rebuildStartDate) + if err != nil { + return err + } + + if _, err := r.sql.ExecContext(ctx, ` + DELETE FROM usage_group_daily_rollups + WHERE bucket_date < $1::date + OR (bucket_date >= $2::date AND bucket_date < $3::date) + OR bucket_date >= $3::date + `, retainedDate, rebuildStartDate, todayDate); err != nil { + return fmt.Errorf("清理分组用量日桶: %w", err) + } + + if _, err := r.sql.ExecContext(ctx, ` + INSERT INTO usage_group_daily_rollups (bucket_date, group_id, actual_cost, computed_at) + SELECT + (created_at AT TIME ZONE $3::text)::date AS bucket_date, + group_id, + COALESCE(SUM(actual_cost), 0) AS actual_cost, + NOW() + FROM usage_logs + WHERE group_id IS NOT NULL + AND created_at >= $1 + AND created_at < $2 + GROUP BY 1, 2 + ON CONFLICT (bucket_date, group_id) + DO UPDATE SET + actual_cost = EXCLUDED.actual_cost, + computed_at = EXCLUDED.computed_at + `, rebuildStart.UTC(), todayStart.UTC(), timezoneName); err != nil { + return fmt.Errorf("重建分组用量日桶: %w", err) + } + + if _, err := r.sql.ExecContext(ctx, ` + UPDATE usage_group_rollup_state + SET closed_before = $1::date, + retained_from = $2, + timezone_name = $3, + updated_at = NOW() + WHERE id = 1 + `, todayDate, retainedFrom, timezoneName); err != nil { + return fmt.Errorf("更新分组用量汇总水位: %w", err) + } + return nil +} + +func lockGroupUsageRollupState(ctx context.Context, tx *sql.Tx) error { + var id int16 + if err := tx.QueryRowContext(ctx, ` + SELECT id + FROM usage_group_rollup_state + WHERE id = 1 + FOR UPDATE + `).Scan(&id); err != nil { + return fmt.Errorf("锁定分组用量汇总水位: %w", err) + } + return nil +} + +func invalidateGroupUsageRollupsAt(ctx context.Context, tx *sql.Tx, affectedAt time.Time) error { + timezoneName := service.GroupUsageTimezoneName() + _, err := tx.ExecContext(ctx, ` + UPDATE usage_group_rollup_state + SET closed_before = LEAST( + closed_before, + ($1::timestamptz AT TIME ZONE $2::text)::date + ), + updated_at = NOW() + WHERE id = 1 + `, affectedAt.UTC(), timezoneName) + return err +} diff --git a/backend/internal/repository/custom_group_usage_timezone_test.go b/backend/internal/repository/custom_group_usage_timezone_test.go new file mode 100644 index 0000000000..c77c5b41bc --- /dev/null +++ b/backend/internal/repository/custom_group_usage_timezone_test.go @@ -0,0 +1,16 @@ +package repository + +import ( + "testing" + + appTimezone "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +func useGroupUsageRepositoryTestTimezone(t *testing.T, name string) { + t.Helper() + + previousName := appTimezone.Name() + require.NoError(t, appTimezone.Init(name)) + t.Cleanup(func() { require.NoError(t, appTimezone.Init(previousName)) }) +} diff --git a/backend/internal/repository/dashboard_aggregation_group_usage_test.go b/backend/internal/repository/dashboard_aggregation_group_usage_test.go new file mode 100644 index 0000000000..cbb5d3c6b6 --- /dev/null +++ b/backend/internal/repository/dashboard_aggregation_group_usage_test.go @@ -0,0 +1,319 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestDashboardAggregationRepositorySyncGroupUsageRollupsNoopsAtCurrentDate(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC) + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow("2026-08-14", time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectCommit() + + require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositorySyncGroupUsageRollupsRebuildsWhenTimezoneChanges(t *testing.T) { + useGroupUsageRepositoryTestTimezone(t, "America/New_York") + + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + retainedFrom := time.Date(2026, 3, 1, 5, 0, 0, 0, time.UTC) + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow("2026-03-09", time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`). + WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(retainedFrom)) + mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`). + WithArgs("2026-03-01", "2026-03-01", "2026-03-09"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`). + WithArgs(retainedFrom, todayStart, "America/New_York"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs("2026-03-09", retainedFrom, "America/New_York"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositorySyncGroupUsageRollupsPublishesWatermarkLast(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC) + retainedFrom := time.Date(2026, 5, 1, 3, 0, 0, 0, time.UTC) + rebuildStart := time.Date(2026, 8, 12, 16, 0, 0, 0, time.UTC) + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow("2026-08-13", time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`). + WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(retainedFrom)) + mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`). + WithArgs("2026-05-01", "2026-08-13", "2026-08-14"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`). + WithArgs(rebuildStart, todayStart, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs("2026-08-14", retainedFrom, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + require.NoError(t, repo.SyncGroupUsageRollups(context.Background(), todayStart)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositorySyncGroupUsageRollupsRejectsFutureWatermark(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC) + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow("2026-08-15", time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectRollback() + + err := repo.SyncGroupUsageRollups(context.Background(), todayStart) + require.ErrorContains(t, err, "未来") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositoryRecomputeRangeInvalidatesGroupRollupsBeforeDashboardRebuild(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + start := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(start, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`DELETE FROM usage_dashboard_hourly`). + WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() + + err := repo.RecomputeRange(context.Background(), start, end) + require.ErrorIs(t, err, sql.ErrConnDone) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositoryRecomputeRangeRebuildsGroupRollupsBeforeCommit(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + start := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC) + repo.clock = func() time.Time { return fixedNow } + todayStart := service.GroupUsageTodayStart(fixedNow) + startDate := service.GroupUsageDate(start) + rebuildStart, err := service.ParseGroupUsageDate(startDate) + require.NoError(t, err) + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(start, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + for _, query := range []string{ + `DELETE FROM usage_dashboard_hourly WHERE`, + `DELETE FROM usage_dashboard_hourly_users WHERE`, + `DELETE FROM usage_dashboard_daily WHERE`, + `DELETE FROM usage_dashboard_daily_users WHERE`, + `INSERT INTO usage_dashboard_hourly_users`, + `INSERT INTO usage_dashboard_daily_users`, + `INSERT INTO usage_dashboard_hourly`, + `INSERT INTO usage_dashboard_daily`, + } { + mock.ExpectExec(query).WillReturnResult(sqlmock.NewResult(0, 1)) + } + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow(startDate, time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectQuery(`SELECT MIN\(created_at\) FROM usage_logs`). + WillReturnRows(sqlmock.NewRows([]string{"min"}).AddRow(start)) + mock.ExpectExec(`DELETE FROM usage_group_daily_rollups`). + WithArgs(startDate, startDate, service.GroupUsageDate(todayStart)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO usage_group_daily_rollups`). + WithArgs(rebuildStart.UTC(), todayStart.UTC(), "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(service.GroupUsageDate(todayStart), start, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + require.NoError(t, repo.RecomputeRange(context.Background(), start, end)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedInvalidatesEachBatchAndSyncs(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + cutoff := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + earliestDeletedAt := time.Date(2026, 5, 3, 2, 0, 0, 0, time.UTC) + fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC) + repo.clock = func() time.Time { return fixedNow } + todayStart := service.GroupUsageTodayStart(fixedNow) + + mock.ExpectQuery(`SELECT EXISTS`). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`(?s)DELETE FROM usage_logs.*RETURNING created_at`). + WithArgs(cutoff, usageLogsCleanupBatchSize). + WillReturnRows(sqlmock.NewRows([]string{"created_at"}). + AddRow(earliestDeletedAt.Add(time.Hour)). + AddRow(earliestDeletedAt)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(earliestDeletedAt, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectBegin() + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow(service.GroupUsageDate(todayStart), time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectCommit() + + require.NoError(t, repo.CleanupUsageLogs(context.Background(), cutoff)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionedSortsAndInvalidatesEachDropBeforeSync(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) + aprilStart := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + juneStart := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + fixedNow := time.Date(2026, 8, 14, 8, 0, 0, 0, time.UTC) + repo.clock = func() time.Time { return fixedNow } + todayStart := service.GroupUsageTodayStart(fixedNow) + + mock.ExpectQuery(`SELECT EXISTS`). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectQuery(`SELECT c.relname`). + WillReturnRows(sqlmock.NewRows([]string{"relname"}). + AddRow("usage_logs_202606"). + AddRow("usage_logs_invalid"). + AddRow("usage_logs_202604"). + AddRow("usage_logs_202607")) + + for _, partition := range []struct { + name string + start time.Time + }{ + {name: "usage_logs_202604", start: aprilStart}, + {name: "usage_logs_202606", start: juneStart}, + } { + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(partition.start, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`DROP TABLE IF EXISTS "` + partition.name + `"`). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + } + mock.ExpectBegin() + mock.ExpectQuery(`SELECT closed_before::text, retained_from.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"closed_before", "retained_from", "timezone_name"}). + AddRow(service.GroupUsageDate(todayStart), time.Unix(0, 0).UTC(), "Asia/Shanghai")) + mock.ExpectCommit() + + require.NoError(t, repo.CleanupUsageLogs(context.Background(), cutoff)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositoryCleanupUsageLogsNonPartitionedFailureRollsBackWithoutSync(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + cutoff := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + deletedAt := time.Date(2026, 5, 3, 2, 0, 0, 0, time.UTC) + + mock.ExpectQuery(`SELECT EXISTS`). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`(?s)SELECT ctid.*ORDER BY created_at ASC, id ASC.*DELETE FROM usage_logs.*RETURNING created_at`). + WithArgs(cutoff, usageLogsCleanupBatchSize). + WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(deletedAt)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(deletedAt, "Asia/Shanghai"). + WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() + + err := repo.CleanupUsageLogs(context.Background(), cutoff) + require.ErrorIs(t, err, sql.ErrConnDone) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDashboardAggregationRepositoryCleanupUsageLogsPartitionFailureRollsBackAndStops(t *testing.T) { + setGroupUsageRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := newDashboardAggregationRepositoryWithSQL(db) + cutoff := time.Date(2026, 7, 18, 0, 0, 0, 0, time.UTC) + aprilStart := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + dropErr := errors.New("drop partition failed") + + mock.ExpectQuery(`SELECT EXISTS`). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectQuery(`SELECT c.relname`). + WillReturnRows(sqlmock.NewRows([]string{"relname"}). + AddRow("usage_logs_202606"). + AddRow("usage_logs_202604")) + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(aprilStart, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`DROP TABLE IF EXISTS "usage_logs_202604"`). + WillReturnError(dropErr) + mock.ExpectRollback() + + err := repo.CleanupUsageLogs(context.Background(), cutoff) + require.ErrorIs(t, err, dropErr) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func setGroupUsageRollupTestTimezone(t *testing.T) { + t.Helper() + useGroupUsageRepositoryTestTimezone(t, "Asia/Shanghai") +} diff --git a/backend/internal/repository/dashboard_aggregation_repo.go b/backend/internal/repository/dashboard_aggregation_repo.go index 5e09e75d40..6701a3c999 100644 --- a/backend/internal/repository/dashboard_aggregation_repo.go +++ b/backend/internal/repository/dashboard_aggregation_repo.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "log" + "sort" "strings" "time" @@ -14,7 +15,8 @@ import ( ) type dashboardAggregationRepository struct { - sql sqlExecutor + sql sqlExecutor + clock func() time.Time } const usageLogsCleanupBatchSize = 10000 @@ -33,7 +35,14 @@ func NewDashboardAggregationRepository(sqlDB *sql.DB) service.DashboardAggregati } func newDashboardAggregationRepositoryWithSQL(sqlq sqlExecutor) *dashboardAggregationRepository { - return &dashboardAggregationRepository{sql: sqlq} + return &dashboardAggregationRepository{sql: sqlq, clock: time.Now} +} + +func (r *dashboardAggregationRepository) now() time.Time { + if r.clock != nil { + return r.clock() + } + return time.Now() } func isPostgresDriver(db *sql.DB) bool { @@ -128,11 +137,23 @@ func (r *dashboardAggregationRepository) RecomputeRange(ctx context.Context, sta if err != nil { return err } + if err := lockGroupUsageRollupState(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + if err := invalidateGroupUsageRollupsAt(ctx, tx, start); err != nil { + _ = tx.Rollback() + return err + } txRepo := newDashboardAggregationRepositoryWithSQL(tx) if err := txRepo.recomputeRangeInTx(ctx, hourStart, hourEnd, dayStart, dayEnd); err != nil { _ = tx.Rollback() return err } + if err := txRepo.syncGroupUsageRollupsInTx(ctx, service.GroupUsageTodayStart(r.now())); err != nil { + _ = tx.Rollback() + return err + } return tx.Commit() } return r.recomputeRangeInTx(ctx, hourStart, hourEnd, dayStart, dayEnd) @@ -215,14 +236,35 @@ func (r *dashboardAggregationRepository) CleanupUsageLogs(ctx context.Context, c return err } if isPartitioned { - return r.dropUsageLogsPartitions(ctx, cutoff) + if err := r.dropUsageLogsPartitions(ctx, cutoff); err != nil { + return err + } + } else if err := r.cleanupUsageLogsBatches(ctx, cutoff); err != nil { + return err } + return r.SyncGroupUsageRollups(ctx, service.GroupUsageTodayStart(r.now())) +} + +func (r *dashboardAggregationRepository) cleanupUsageLogsBatches(ctx context.Context, cutoff time.Time) error { + db, transactional := r.sql.(*sql.DB) for { + if transactional { + affected, err := cleanupUsageLogsBatchWithRollupInvalidation(ctx, db, cutoff) + if err != nil { + return err + } + if affected < usageLogsCleanupBatchSize { + return nil + } + continue + } + res, err := r.sql.ExecContext(ctx, ` WITH victims AS ( SELECT ctid FROM usage_logs WHERE created_at < $1 + ORDER BY created_at ASC, id ASC LIMIT $2 ) DELETE FROM usage_logs @@ -241,6 +283,66 @@ func (r *dashboardAggregationRepository) CleanupUsageLogs(ctx context.Context, c } } +func cleanupUsageLogsBatchWithRollupInvalidation(ctx context.Context, db *sql.DB, cutoff time.Time) (int64, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + rollback := func(err error) (int64, error) { + _ = tx.Rollback() + return 0, err + } + + if err := lockGroupUsageRollupState(ctx, tx); err != nil { + return rollback(err) + } + rows, err := tx.QueryContext(ctx, ` + WITH victims AS ( + SELECT ctid + FROM usage_logs + WHERE created_at < $1 + ORDER BY created_at ASC, id ASC + LIMIT $2 + ) + DELETE FROM usage_logs + WHERE ctid IN (SELECT ctid FROM victims) + RETURNING created_at + `, cutoff.UTC(), usageLogsCleanupBatchSize) + if err != nil { + return rollback(err) + } + + var affected int64 + var earliestDeletedAt time.Time + for rows.Next() { + var deletedAt time.Time + if err := rows.Scan(&deletedAt); err != nil { + _ = rows.Close() + return rollback(err) + } + affected++ + if earliestDeletedAt.IsZero() || deletedAt.Before(earliestDeletedAt) { + earliestDeletedAt = deletedAt + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return rollback(err) + } + if err := rows.Close(); err != nil { + return rollback(err) + } + if affected > 0 { + if err := invalidateGroupUsageRollupsAt(ctx, tx, earliestDeletedAt); err != nil { + return rollback(err) + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return affected, nil +} + func (r *dashboardAggregationRepository) CleanupUsageBillingDedup(ctx context.Context, cutoff time.Time) error { for { res, err := r.sql.ExecContext(ctx, ` @@ -489,14 +591,16 @@ func (r *dashboardAggregationRepository) dropUsageLogsPartitions(ctx context.Con if err != nil { return err } - defer func() { - _ = rows.Close() - }() - cutoffMonth := truncateToMonthUTC(cutoff) + type usageLogsPartition struct { + name string + month time.Time + } + partitions := make([]usageLogsPartition, 0) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { + _ = rows.Close() return err } if !strings.HasPrefix(name, "usage_logs_") { @@ -509,12 +613,56 @@ func (r *dashboardAggregationRepository) dropUsageLogsPartitions(ctx context.Con } month = month.UTC() if month.Before(cutoffMonth) { - if _, err := r.sql.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", pq.QuoteIdentifier(name))); err != nil { + partitions = append(partitions, usageLogsPartition{name: name, month: month}) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + sort.Slice(partitions, func(i, j int) bool { + return partitions[i].month.Before(partitions[j].month) + }) + if db, ok := r.sql.(*sql.DB); ok { + for _, partition := range partitions { + if err := dropUsageLogsPartitionWithRollupInvalidation(ctx, db, partition.name, partition.month); err != nil { return err } } + return nil } - return rows.Err() + for _, partition := range partitions { + if _, err := r.sql.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", pq.QuoteIdentifier(partition.name))); err != nil { + return err + } + } + return nil +} + +func dropUsageLogsPartitionWithRollupInvalidation(ctx context.Context, db *sql.DB, name string, monthStart time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + rollback := func(err error) error { + _ = tx.Rollback() + return err + } + + if err := lockGroupUsageRollupState(ctx, tx); err != nil { + return rollback(err) + } + if err := invalidateGroupUsageRollupsAt(ctx, tx, monthStart); err != nil { + return rollback(err) + } + if _, err := tx.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", pq.QuoteIdentifier(name))); err != nil { + return rollback(err) + } + return tx.Commit() } func (r *dashboardAggregationRepository) createUsageLogsPartition(ctx context.Context, month time.Time) error { diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index dc3d4a980a..36935160da 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -15,6 +15,7 @@ import ( ) const stickySessionPrefix = "sticky_session:" +const openAIResponsesSessionWindowPrefix = "openai_responses_session_window:" const liveCallPrefix = "live:call:" type gatewayCache struct { @@ -31,6 +32,10 @@ func buildSessionKey(groupID int64, sessionHash string) string { return fmt.Sprintf("%s%d:%s", stickySessionPrefix, groupID, sessionHash) } +func buildOpenAIResponsesSessionWindowKey(groupID int64, sessionHash string) string { + return fmt.Sprintf("%s%d:%s", openAIResponsesSessionWindowPrefix, groupID, sessionHash) +} + func (c *gatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) { key := buildSessionKey(groupID, sessionHash) accountID, err := c.rdb.Get(ctx, key).Int64() @@ -65,6 +70,97 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64 return c.rdb.Del(ctx, key).Err() } +var claimOpenAIResponsesSessionWindowScript = redis.NewScript(` +local previous = redis.call('GET', KEYS[1]) +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) +return previous +`) + +var compareAndRefreshOpenAIResponsesSessionWindowScript = redis.NewScript(` +local current = redis.call('GET', KEYS[1]) +if current == false or current ~= ARGV[1] then + return 0 +end +redis.call('PEXPIRE', KEYS[1], ARGV[2]) +return 1 +`) + +var compareAndDeleteOpenAIResponsesSessionWindowScript = redis.NewScript(` +local current = redis.call('GET', KEYS[1]) +if current == false or current ~= ARGV[1] then + return 0 +end +redis.call('DEL', KEYS[1]) +return 1 +`) + +func (c *gatewayCache) ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error) { + if c == nil || c.rdb == nil { + return nil, errors.New("gateway cache unavailable") + } + if len(owner) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 { + return nil, errors.New("invalid OpenAI Responses session-window claim") + } + result, err := claimOpenAIResponsesSessionWindowScript.Run( + ctx, + c.rdb, + []string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)}, + owner, + ttl.Milliseconds(), + ).Result() + if errors.Is(err, redis.Nil) { + return nil, nil + } + if err != nil { + return nil, err + } + switch value := result.(type) { + case nil: + return nil, nil + case string: + return []byte(value), nil + case []byte: + return append([]byte(nil), value...), nil + default: + return nil, fmt.Errorf("unexpected OpenAI Responses session-window claim result %T", result) + } +} + +func (c *gatewayCache) CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error) { + if c == nil || c.rdb == nil { + return false, errors.New("gateway cache unavailable") + } + if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 { + return false, errors.New("invalid OpenAI Responses session-window refresh") + } + n, err := compareAndRefreshOpenAIResponsesSessionWindowScript.Run( + ctx, + c.rdb, + []string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)}, + expected, + ttl.Milliseconds(), + ).Int() + return n == 1, err +} + +func (c *gatewayCache) CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error) { + if c == nil || c.rdb == nil { + return false, errors.New("gateway cache unavailable") + } + if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" { + return false, errors.New("invalid OpenAI Responses session-window delete") + } + n, err := compareAndDeleteOpenAIResponsesSessionWindowScript.Run( + ctx, + c.rdb, + []string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)}, + expected, + ).Int() + return n == 1, err +} + +var _ service.OpenAIWSSessionPreemptionCache = (*gatewayCache)(nil) + const ( grokVideoPendingBillingPrefix = "grok_video_pending:" grokVideoBilledPrefix = "grok_video_billed:" @@ -131,23 +227,128 @@ func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) e var _ service.CyberSessionBlockStore = (*gatewayCache)(nil) var _ service.LiveCallStore = (*gatewayCache)(nil) -const cyberSessionBlockPrefix = "cyber_session_block:" +const reasoningContentPrefix = "reasoning_content:" -// SetCyberSessionBlocked 把被 cyber_policy 命中的会话写入屏蔽表(TTL 自动过期)。 -// 存储值 "1" 作为存在标记(IsCyberSessionBlocked 只检查 key 是否存在,不读值)。 -func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error { - return c.rdb.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl).Err() +// reasoningContentDefaultTTL 是 reasoning 缓存的默认过期时间。Codex 会话可能 +// 跨多天恢复,取 7 天;调用方传入非正 TTL 时兜底。 +const reasoningContentDefaultTTL = 7 * 24 * time.Hour + +// SetReasoningContent 按 reasoning item id 缓存 reasoning 全文。 +// itemID 或 content 为空时直接返回 nil(无可缓存内容,属正常情况而非错误)。 +func (c *gatewayCache) SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error { + if c == nil || c.rdb == nil { + return errors.New("gateway cache unavailable") + } + itemID = strings.TrimSpace(itemID) + if itemID == "" || content == "" { + return nil + } + if ttl <= 0 { + ttl = reasoningContentDefaultTTL + } + return c.rdb.Set(ctx, reasoningContentPrefix+itemID, content, ttl).Err() } -// IsCyberSessionBlocked 查询会话是否在屏蔽表中。 -func (c *gatewayCache) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) { - n, err := c.rdb.Exists(ctx, cyberSessionBlockPrefix+key).Result() +// GetReasoningContent 返回缓存的 reasoning 全文;未命中返回 +// service.ErrReasoningContentNotFound。 +func (c *gatewayCache) GetReasoningContent(ctx context.Context, itemID string) (string, error) { + if c == nil || c.rdb == nil { + return "", errors.New("gateway cache unavailable") + } + itemID = strings.TrimSpace(itemID) + if itemID == "" { + return "", service.ErrReasoningContentNotFound + } + val, err := c.rdb.Get(ctx, reasoningContentPrefix+itemID).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return "", service.ErrReasoningContentNotFound + } + return "", err + } + return val, nil +} + +const ( + cyberSessionBlockPrefix = "cyber_session_block:" + cyberSessionScopePrefix = "cyber_session_scope:" + cyberSessionRedisCommandMaxKeys = 128 +) + +// SetCyberSessionBlocked writes exact blocks in bounded transactions. The +// coarse scope is activated only after all exact blocks have been stored. +func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error { + if len(keys) == 0 { + return nil + } + exactKeys := make([]string, 0, cyberSessionRedisCommandMaxKeys) + flush := func() error { + if len(exactKeys) == 0 { + return nil + } + pipe := c.rdb.TxPipeline() + for _, key := range exactKeys { + pipe.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl) + } + _, err := pipe.Exec(ctx) + exactKeys = exactKeys[:0] + return err + } + for _, key := range keys { + if key != "" { + exactKeys = append(exactKeys, key) + if len(exactKeys) == cyberSessionRedisCommandMaxKeys { + if err := flush(); err != nil { + return err + } + } + } + } + if err := flush(); err != nil { + return err + } + if scopeKey != "" { + return c.rdb.Set(ctx, cyberSessionScopePrefix+scopeKey, "1", ttl).Err() + } + return nil +} + +func (c *gatewayCache) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) { + n, err := c.rdb.Exists(ctx, cyberSessionScopePrefix+scopeKey).Result() if err != nil { return false, err } return n > 0, nil } +// FindCyberSessionBlocked checks bounded batches in caller order and stops at +// the first blocked key, preserving the original earliest-match behavior. +func (c *gatewayCache) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) { + if len(keys) == 0 { + return "", nil + } + for start := 0; start < len(keys); start += cyberSessionRedisCommandMaxKeys { + end := start + cyberSessionRedisCommandMaxKeys + if end > len(keys) { + end = len(keys) + } + redisKeys := make([]string, end-start) + for i, key := range keys[start:end] { + redisKeys[i] = cyberSessionBlockPrefix + key + } + values, err := c.rdb.MGet(ctx, redisKeys...).Result() + if err != nil { + return "", err + } + for i, value := range values { + if value != nil { + return keys[start+i], nil + } + } + } + return "", nil +} + var claimLiveControllerScript = redis.NewScript(` local key = KEYS[1] local target = ARGV[1] diff --git a/backend/internal/repository/gateway_cache_cyber_test.go b/backend/internal/repository/gateway_cache_cyber_test.go new file mode 100644 index 0000000000..03f29e82a1 --- /dev/null +++ b/backend/internal/repository/gateway_cache_cyber_test.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + "strconv" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +type cyberRedisCommandHook struct { + mu sync.Mutex + mgetKeyCounts []int + setBatchSizes []int +} + +func (h *cyberRedisCommandHook) DialHook(next redis.DialHook) redis.DialHook { return next } + +func (h *cyberRedisCommandHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { + return func(ctx context.Context, cmd redis.Cmder) error { + if cmd.Name() == "mget" { + h.mu.Lock() + h.mgetKeyCounts = append(h.mgetKeyCounts, len(cmd.Args())-1) + h.mu.Unlock() + } + return next(ctx, cmd) + } +} + +func (h *cyberRedisCommandHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { + return func(ctx context.Context, cmds []redis.Cmder) error { + setCount := 0 + for _, cmd := range cmds { + if cmd.Name() == "set" { + setCount++ + } + } + if setCount > 0 { + h.mu.Lock() + h.setBatchSizes = append(h.setBatchSizes, setCount) + h.mu.Unlock() + } + return next(ctx, cmds) + } +} + +func TestGatewayCacheCyberBlockWritesScopeAndExactKeysTogether(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + store, ok := NewGatewayCache(client).(service.CyberSessionBlockStore) + require.True(t, ok) + + ctx := context.Background() + require.NoError(t, store.SetCyberSessionBlocked(ctx, "scope-1", []string{"block-1", "block-2"}, time.Minute)) + active, err := store.IsCyberSessionScopeActive(ctx, "scope-1") + require.NoError(t, err) + require.True(t, active) + matched, err := store.FindCyberSessionBlocked(ctx, []string{"missing", "block-1", "block-2"}) + require.NoError(t, err) + require.Equal(t, "block-1", matched) + require.Greater(t, server.TTL(cyberSessionScopePrefix+"scope-1"), time.Duration(0)) + require.Equal(t, server.TTL(cyberSessionBlockPrefix+"block-1"), server.TTL(cyberSessionBlockPrefix+"block-2")) +} + +func TestGatewayCacheCyberBlockCommandsAreBoundedAndLookupShortCircuits(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + hook := &cyberRedisCommandHook{} + client.AddHook(hook) + store, ok := NewGatewayCache(client).(service.CyberSessionBlockStore) + require.True(t, ok) + + keys := make([]string, cyberSessionRedisCommandMaxKeys*2+44) + for i := range keys { + keys[i] = "block-" + strconv.Itoa(i) + } + ctx := context.Background() + require.NoError(t, store.SetCyberSessionBlocked(ctx, "large-scope", keys, time.Minute)) + require.Equal(t, []int{cyberSessionRedisCommandMaxKeys, cyberSessionRedisCommandMaxKeys, 44}, hook.setBatchSizes) + + lookup := make([]string, len(keys)) + for i := range lookup { + lookup[i] = "missing-" + strconv.Itoa(i) + } + lookup[cyberSessionRedisCommandMaxKeys+3] = keys[cyberSessionRedisCommandMaxKeys+3] + matched, err := store.FindCyberSessionBlocked(ctx, lookup) + require.NoError(t, err) + require.Equal(t, keys[cyberSessionRedisCommandMaxKeys+3], matched) + require.Equal(t, []int{cyberSessionRedisCommandMaxKeys, cyberSessionRedisCommandMaxKeys}, hook.mgetKeyCounts) +} diff --git a/backend/internal/repository/gateway_cache_reasoning_test.go b/backend/internal/repository/gateway_cache_reasoning_test.go new file mode 100644 index 0000000000..f284da51b1 --- /dev/null +++ b/backend/internal/repository/gateway_cache_reasoning_test.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestGatewayCacheReasoningContent(t *testing.T) { + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + cache := NewGatewayCache(client) + ctx := context.Background() + + // 未命中返回哨兵错误,区别于真实读取失败。 + _, err := cache.GetReasoningContent(ctx, "item_missing") + require.ErrorIs(t, err, service.ErrReasoningContentNotFound) + + // 写入后可读回。 + require.NoError(t, cache.SetReasoningContent(ctx, "item_abc", "think hard", time.Minute)) + got, err := cache.GetReasoningContent(ctx, "item_abc") + require.NoError(t, err) + require.Equal(t, "think hard", got) + + // ttl<=0 时兜底为默认 7 天。 + require.NoError(t, cache.SetReasoningContent(ctx, "item_ttl", "x", 0)) + ttl := mr.TTL(reasoningContentPrefix + "item_ttl") + require.Greater(t, ttl, 6*24*time.Hour) + require.LessOrEqual(t, ttl, reasoningContentDefaultTTL) + + // 空 itemID / 空 content 是 no-op(无可缓存内容不算错误)。 + require.NoError(t, cache.SetReasoningContent(ctx, "", "x", time.Minute)) + require.NoError(t, cache.SetReasoningContent(ctx, "item_empty", "", time.Minute)) + _, err = cache.GetReasoningContent(ctx, "item_empty") + require.ErrorIs(t, err, service.ErrReasoningContentNotFound) +} diff --git a/backend/internal/repository/group_usage_rollup_trigger_integration_test.go b/backend/internal/repository/group_usage_rollup_trigger_integration_test.go new file mode 100644 index 0000000000..32016e3c0a --- /dev/null +++ b/backend/internal/repository/group_usage_rollup_trigger_integration_test.go @@ -0,0 +1,518 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/migrations" + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestGroupUsageRollupTriggerInvalidatesCascadedHistoricalDelete(t *testing.T) { + for _, partitioned := range []bool{false, true} { + name := "ordinary" + if partitioned { + name = "partitioned" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, partitioned) + tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = tx.Rollback() }() + + _, err := tx.ExecContext(ctx, ` + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) + VALUES (1, 1, 10, 1.25, TIMESTAMPTZ '2020-01-02 08:00:00+08'); + UPDATE usage_group_rollup_state + SET closed_before = (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + WHERE id = 1; + DELETE FROM users WHERE id = 1; + `) + require.NoError(t, err) + + var closedBefore string + err = tx.QueryRowContext(ctx, ` + SELECT closed_before::text + FROM usage_group_rollup_state + WHERE id = 1 + `).Scan(&closedBefore) + require.NoError(t, err) + require.Equal(t, "2020-01-02", closedBefore) + }) + } +} + +func TestGroupUsageRollupTriggerSerializesLateHistoricalInsertWithPublish(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + seedTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + _, err := seedTx.ExecContext(ctx, ` + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + UPDATE usage_group_rollup_state + SET closed_before = DATE '2020-01-02' + WHERE id = 1; + `) + require.NoError(t, err) + require.NoError(t, seedTx.Commit()) + + syncTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = syncTx.Rollback() }() + var stateID int16 + require.NoError(t, syncTx.QueryRowContext(ctx, ` + SELECT id + FROM usage_group_rollup_state + WHERE id = 1 + FOR UPDATE + `).Scan(&stateID)) + + lateTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = lateTx.Rollback() }() + var lateBackendPID int + require.NoError(t, lateTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&lateBackendPID)) + + insertResult := make(chan error, 1) + go func() { + _, insertErr := lateTx.ExecContext(ctx, ` + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) + VALUES (1, 1, 10, 1.25, TIMESTAMPTZ '2020-01-02 09:00:00+08') + `) + insertResult <- insertErr + }() + + blocked, err := waitForGroupUsageRollupStateLock(ctx, lateBackendPID, insertResult) + if err != nil || !blocked { + _ = syncTx.Rollback() + _ = lateTx.Rollback() + require.NoError(t, err) + require.True(t, blocked, "迟到写入必须等待正在发布水位的事务") + } + + _, err = syncTx.ExecContext(ctx, ` + UPDATE usage_group_rollup_state + SET closed_before = (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + WHERE id = 1 + `) + require.NoError(t, err) + require.NoError(t, syncTx.Commit()) + + select { + case err = <-insertResult: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("等待迟到写入完成超时") + } + require.NoError(t, lateTx.Commit()) + + var closedBefore string + err = integrationDB.QueryRowContext(ctx, fmt.Sprintf( + "SELECT closed_before::text FROM %s.usage_group_rollup_state WHERE id = 1", + pq.QuoteIdentifier(schema), + )).Scan(&closedBefore) + require.NoError(t, err) + require.Equal(t, "2020-01-02", closedBefore) +} + +func TestGroupUsageRollupTriggerSerializesInsertTransactionAcrossMidnight(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + seedTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + _, err := seedTx.ExecContext(ctx, ` + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + UPDATE usage_group_rollup_state + SET closed_before = (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + WHERE id = 1; + `) + require.NoError(t, err) + require.NoError(t, seedTx.Commit()) + + syncTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = syncTx.Rollback() }() + var stateID int16 + require.NoError(t, syncTx.QueryRowContext(ctx, ` + SELECT id + FROM usage_group_rollup_state + WHERE id = 1 + FOR UPDATE + `).Scan(&stateID)) + + insertTx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = insertTx.Rollback() }() + require.NoError(t, setGroupUsageRollupTriggerTimeZone(ctx, insertTx, "Asia/Shanghai")) + var insertBackendPID int + require.NoError(t, insertTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&insertBackendPID)) + + insertResult := make(chan error, 1) + go func() { + _, insertErr := insertTx.ExecContext(ctx, ` + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) + VALUES (1, 1, 10, 1.25, CURRENT_TIMESTAMP) + `) + insertResult <- insertErr + }() + + blocked, err := waitForGroupUsageRollupStateLock(ctx, insertBackendPID, insertResult) + if err != nil || !blocked { + _ = syncTx.Rollback() + _ = insertTx.Rollback() + require.NoError(t, err) + require.True(t, blocked, "跨越零点的在途写入必须与水位发布串行化") + } + + _, err = syncTx.ExecContext(ctx, ` + UPDATE usage_group_rollup_state + SET closed_before = (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + 1 + WHERE id = 1 + `) + require.NoError(t, err) + require.NoError(t, syncTx.Commit()) + + select { + case err = <-insertResult: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("等待跨零点写入完成超时") + } + require.NoError(t, insertTx.Commit()) + + var currentDate string + require.NoError(t, integrationDB.QueryRowContext(ctx, ` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date::text + `).Scan(¤tDate)) + var closedBefore string + err = integrationDB.QueryRowContext(ctx, fmt.Sprintf( + "SELECT closed_before::text FROM %s.usage_group_rollup_state WHERE id = 1", + pq.QuoteIdentifier(schema), + )).Scan(&closedBefore) + require.NoError(t, err) + require.Equal(t, currentDate, closedBefore) +} + +func TestGroupUsageRollupTriggerKeepsWatermarkForTodayInsert(t *testing.T) { + ctx := context.Background() + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + + tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = tx.Rollback() }() + require.NoError(t, setGroupUsageRollupTriggerTimeZone(ctx, tx, "Asia/Shanghai")) + _, err := tx.ExecContext(ctx, ` + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + UPDATE usage_group_rollup_state + SET closed_before = (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + WHERE id = 1; + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) + VALUES (1, 1, 10, 1.25, CURRENT_TIMESTAMP); + `) + require.NoError(t, err) + + var unchanged bool + err = tx.QueryRowContext(ctx, ` + SELECT closed_before = (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + FROM usage_group_rollup_state + WHERE id = 1 + `).Scan(&unchanged) + require.NoError(t, err) + require.True(t, unchanged) +} + +func TestGroupUsageRollupTriggerUsesSessionTimezoneAcrossDST(t *testing.T) { + ctx := context.Background() + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + + tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = tx.Rollback() }() + _, err := tx.ExecContext(ctx, ` + SET LOCAL TIME ZONE 'America/New_York'; + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + UPDATE usage_group_rollup_state + SET closed_before = DATE '2026-03-09', + timezone_name = 'America/New_York' + WHERE id = 1; + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) + VALUES (1, 1, 10, 1.25, TIMESTAMPTZ '2026-03-08 04:30:00+00'); + `) + require.NoError(t, err) + + var closedBefore string + err = tx.QueryRowContext(ctx, ` + SELECT closed_before::text + FROM usage_group_rollup_state + WHERE id = 1 + `).Scan(&closedBefore) + require.NoError(t, err) + require.Equal(t, "2026-03-07", closedBefore) +} + +func TestGroupUsageSummaryIncludesYesterdayAcrossWatermark(t *testing.T) { + ctx := context.Background() + useGroupUsageRepositoryTestTimezone(t, "Asia/Shanghai") + todayStart := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC) + + tests := []struct { + name string + closedBefore string + includeYesterday bool + }{ + {name: "closed_rollup", closedBefore: "2026-08-14", includeYesterday: true}, + {name: "raw_tail", closedBefore: "2026-08-13", includeYesterday: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = tx.Rollback() }() + + _, err := tx.ExecContext(ctx, ` + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) VALUES + (1, 1, 10, 2, TIMESTAMPTZ '2026-08-12 12:00:00+08'), + (2, 1, 10, 3, TIMESTAMPTZ '2026-08-13 12:00:00+08'), + (3, 1, 10, 4, TIMESTAMPTZ '2026-08-14 12:00:00+08'); + INSERT INTO usage_group_daily_rollups (bucket_date, group_id, actual_cost, computed_at) + VALUES (DATE '2026-08-12', 10, 2, NOW()); + `) + require.NoError(t, err) + if tt.includeYesterday { + _, err = tx.ExecContext(ctx, ` + INSERT INTO usage_group_daily_rollups (bucket_date, group_id, actual_cost, computed_at) + VALUES (DATE '2026-08-13', 10, 3, NOW()) + `) + require.NoError(t, err) + } + _, err = tx.ExecContext(ctx, ` + UPDATE usage_group_rollup_state + SET closed_before = $1::date, + retained_from = TIMESTAMPTZ '2026-08-12 00:00:00+08' + WHERE id = 1 + `, tt.closedBefore) + require.NoError(t, err) + + repo := newUsageLogRepositoryWithSQL(nil, tx) + result, err := repo.GetAllGroupUsageSummary(ctx, todayStart) + require.NoError(t, err) + require.Len(t, result, 1) + require.InDelta(t, 9, result[0].TotalCost, 0.0000001) + require.InDelta(t, 4, result[0].TodayCost, 0.0000001) + require.InDelta(t, 3, result[0].YesterdayCost, 0.0000001) + }) + } +} + +func TestGroupUsageRollupSyncRebuildsAfterTimezoneChange(t *testing.T) { + ctx := context.Background() + useGroupUsageRepositoryTestTimezone(t, "America/New_York") + todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = tx.Rollback() }() + + _, err := tx.ExecContext(ctx, ` + SET LOCAL TIME ZONE 'UTC'; + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) VALUES + (1, 1, 10, 3, TIMESTAMPTZ '2026-03-08 05:30:00+00'), + (2, 1, 10, 5, TIMESTAMPTZ '2026-03-09 04:30:00+00'); + INSERT INTO usage_group_daily_rollups (bucket_date, group_id, actual_cost, computed_at) + VALUES (DATE '2026-03-08', 10, 99, NOW()); + UPDATE usage_group_rollup_state + SET closed_before = DATE '2026-03-09', + retained_from = TIMESTAMPTZ '2026-03-08 05:30:00+00', + timezone_name = 'Asia/Shanghai' + WHERE id = 1; + `) + require.NoError(t, err) + + repo := newDashboardAggregationRepositoryWithSQL(tx) + require.NoError(t, repo.SyncGroupUsageRollups(ctx, todayStart)) + + var stateTimezone string + var closedBefore string + require.NoError(t, tx.QueryRowContext(ctx, ` + SELECT timezone_name, closed_before::text + FROM usage_group_rollup_state + WHERE id = 1 + `).Scan(&stateTimezone, &closedBefore)) + require.Equal(t, "America/New_York", stateTimezone) + require.Equal(t, "2026-03-09", closedBefore) + + var rollupCost float64 + require.NoError(t, tx.QueryRowContext(ctx, ` + SELECT actual_cost + FROM usage_group_daily_rollups + WHERE bucket_date = DATE '2026-03-08' AND group_id = 10 + `).Scan(&rollupCost)) + require.InDelta(t, 3, rollupCost, 0.0000001) + + usageRepo := newUsageLogRepositoryWithSQL(nil, tx) + result, err := usageRepo.GetAllGroupUsageSummary(ctx, todayStart) + require.NoError(t, err) + require.Len(t, result, 1) + require.InDelta(t, 8, result[0].TotalCost, 0.0000001) + require.InDelta(t, 5, result[0].TodayCost, 0.0000001) + require.InDelta(t, 3, result[0].YesterdayCost, 0.0000001) +} + +func TestGroupUsageSummaryUsesConfiguredDSTBoundaries(t *testing.T) { + ctx := context.Background() + useGroupUsageRepositoryTestTimezone(t, "America/New_York") + todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + schema := createGroupUsageRollupTriggerTestSchema(t, ctx, false) + tx := beginGroupUsageRollupTriggerTestTx(t, ctx, schema) + defer func() { _ = tx.Rollback() }() + + _, err := tx.ExecContext(ctx, ` + SET LOCAL TIME ZONE 'UTC'; + INSERT INTO groups (id) VALUES (10); + INSERT INTO users (id) VALUES (1); + INSERT INTO usage_logs (id, user_id, group_id, actual_cost, created_at) VALUES + (1, 1, 10, 100, TIMESTAMPTZ '2026-03-08 04:30:00+00'), + (2, 1, 10, 3, TIMESTAMPTZ '2026-03-08 05:30:00+00'), + (3, 1, 10, 4, TIMESTAMPTZ '2026-03-09 03:30:00+00'), + (4, 1, 10, 5, TIMESTAMPTZ '2026-03-09 04:30:00+00'); + UPDATE usage_group_rollup_state + SET closed_before = DATE '1970-01-01', + retained_from = TIMESTAMPTZ '1970-01-01 00:00:00+00', + timezone_name = 'America/New_York' + WHERE id = 1; + `) + require.NoError(t, err) + + repo := newUsageLogRepositoryWithSQL(nil, tx) + result, err := repo.GetAllGroupUsageSummary(ctx, todayStart) + require.NoError(t, err) + require.Len(t, result, 1) + require.InDelta(t, 112, result[0].TotalCost, 0.0000001) + require.InDelta(t, 5, result[0].TodayCost, 0.0000001) + require.InDelta(t, 7, result[0].YesterdayCost, 0.0000001) +} + +func createGroupUsageRollupTriggerTestSchema(t *testing.T, ctx context.Context, partitioned bool) string { + t.Helper() + + schema := fmt.Sprintf("group_usage_rollup_trigger_%d", time.Now().UnixNano()) + quotedSchema := pq.QuoteIdentifier(schema) + _, err := integrationDB.ExecContext(ctx, "CREATE SCHEMA "+quotedSchema) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DROP SCHEMA IF EXISTS "+quotedSchema+" CASCADE") + }) + + tx, err := integrationDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer func() { _ = tx.Rollback() }() + require.NoError(t, setGroupUsageRollupTriggerSearchPath(ctx, tx, quotedSchema)) + + usageLogsDDL := ` + CREATE TABLE usage_logs ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL, + actual_cost NUMERIC(20, 10) NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ); + ` + if partitioned { + usageLogsDDL = ` + CREATE TABLE usage_logs ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL, + actual_cost NUMERIC(20, 10) NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ) PARTITION BY RANGE (created_at); + CREATE TABLE usage_logs_default PARTITION OF usage_logs DEFAULT; + ` + } + + _, err = tx.ExecContext(ctx, ` + CREATE TABLE users (id BIGINT PRIMARY KEY); + CREATE TABLE groups (id BIGINT PRIMARY KEY); + `+usageLogsDDL) + require.NoError(t, err) + + for _, migrationName := range []string{ + "222_group_usage_daily_rollups.sql", + "223_group_usage_rollup_timezone.sql", + } { + migrationSQL, readErr := migrations.FS.ReadFile(migrationName) + require.NoError(t, readErr) + for range 2 { + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + } + } + require.NoError(t, tx.Commit()) + + return schema +} + +func beginGroupUsageRollupTriggerTestTx(t *testing.T, ctx context.Context, schema string) *sql.Tx { + t.Helper() + + tx, err := integrationDB.BeginTx(ctx, nil) + require.NoError(t, err) + require.NoError(t, setGroupUsageRollupTriggerSearchPath(ctx, tx, pq.QuoteIdentifier(schema))) + return tx +} + +func setGroupUsageRollupTriggerSearchPath(ctx context.Context, tx *sql.Tx, quotedSchema string) error { + _, err := tx.ExecContext(ctx, "SET LOCAL search_path TO "+quotedSchema) + return err +} + +func setGroupUsageRollupTriggerTimeZone(ctx context.Context, tx *sql.Tx, name string) error { + _, err := tx.ExecContext(ctx, "SET LOCAL TIME ZONE "+pq.QuoteLiteral(name)) + return err +} + +func waitForGroupUsageRollupStateLock( + ctx context.Context, + backendPID int, + insertResult <-chan error, +) (bool, error) { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case err := <-insertResult: + if err != nil { + return false, err + } + return false, nil + case <-ticker.C: + var waitEventType sql.NullString + err := integrationDB.QueryRowContext(ctx, ` + SELECT wait_event_type + FROM pg_stat_activity + WHERE pid = $1 + `, backendPID).Scan(&waitEventType) + if err != nil { + return false, err + } + if waitEventType.Valid && waitEventType.String == "Lock" { + return true, nil + } + case <-ctx.Done(): + return false, ctx.Err() + } + } +} diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 904ff09eb9..bdd6478cff 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -99,6 +99,7 @@ const ( upstreamProtocolModeOpenAIH1 = "openai_h1" upstreamProtocolModeOpenAIH2 = "openai_h2" upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback" + upstreamProtocolModeGrok = "grok" ) var errUpstreamClientLimitReached = errors.New("upstream client cache limit reached") @@ -899,12 +900,20 @@ func (s *httpUpstreamService) resolvePoolSettings(isolation string, accountConcu } func (s *httpUpstreamService) applyProfilePoolSettings(settings poolSettings, profile service.HTTPUpstreamProfile) poolSettings { - if profile != service.HTTPUpstreamProfileOpenAI { - return settings - } - settings.responseHeaderTimeout = 0 - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIResponseHeaderTimeout > 0 { - settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.OpenAIResponseHeaderTimeout) * time.Second + switch profile { + case service.HTTPUpstreamProfileOpenAI: + settings.responseHeaderTimeout = 0 + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIResponseHeaderTimeout > 0 { + settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.OpenAIResponseHeaderTimeout) * time.Second + } + case service.HTTPUpstreamProfileGrok: + // Grok can stall before its first byte under capacity pressure. Keep the + // generic 600s gateway timeout from turning one request into a 10-minute + // resource hold; streaming after headers is unaffected. + settings.responseHeaderTimeout = 120 * time.Second + if s != nil && s.cfg != nil { + settings.responseHeaderTimeout = time.Duration(s.cfg.Gateway.GrokResponseHeaderTimeout) * time.Second + } } return settings } @@ -983,6 +992,9 @@ func (s *httpUpstreamService) resolveOpenAIHTTP2Settings() openAIHTTP2Settings { } func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamProfile, proxyKey string, parsedProxy *url.URL) string { + if profile == service.HTTPUpstreamProfileGrok { + return upstreamProtocolModeGrok + } if profile != service.HTTPUpstreamProfileOpenAI { return upstreamProtocolModeDefault } diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index ead61da3b2..66a30dab0e 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -1,7 +1,10 @@ package repository import ( + "context" + "crypto/x509" "net/http" + "net/http/httptest" "net/url" "testing" "time" @@ -19,6 +22,16 @@ func http2KeepAliveTestPoolSettings() poolSettings { } } +// requireHTTP2Configured 断言 http2 已显式挂到 http.Transport 上。 +// x/net/http2 在 go1.27 && !http2legacy 下是标准库 HTTP/2 的包装:ConfigureTransports 通过 +// Transport.RegisterProtocol("http/2") 注册配置并打开 Protocols.HTTP2(TLSNextProto 不承载 h2 入口), +// ReadIdleTimeout/PingTimeout 在建连时映射为 http.HTTP2Config.SendPingTimeout/PingTimeout。 +func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) { + t.Helper() + require.NotNil(t, tr.Protocols, msg) + require.True(t, tr.Protocols.HTTP2(), msg) +} + // Codex/OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: // 两端都以为连接存活,请求落上去会挂到 TCP 重传超时(分钟级)才失败。Go 的 // http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),无法检测这种死连接。 @@ -34,7 +47,7 @@ func TestEnableOpenAIHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接") require.Equal(t, openAIHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) require.Equal(t, openAIHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") - require.NotNil(t, tr.TLSNextProto["h2"], "http2 必须已挂到底层 http.Transport 上") + requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") } // openai_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 @@ -42,17 +55,45 @@ func TestBuildUpstreamTransport_OpenAIH2_EnablesPingHealthCheck(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2, "openai_h2 必须启用 HTTP/2") - require.NotNil(t, tr.TLSNextProto["h2"], "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") + requireHTTP2Configured(t, tr, "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") } // 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置, -// 构建时 TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 +// 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 func TestBuildUpstreamTransport_NonOpenAIH2_NotEagerlyConfigured(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault) require.NoError(t, err) + require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive") require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive") } +// openai_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: +// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableOpenAIHTTP2KeepAlive 的显式配置。 +func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) + require.NoError(t, err) + defer tr.CloseIdleConnections() + require.NotNil(t, tr.TLSClientConfig) + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + tr.TLSClientConfig.RootCAs = roots + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := tr.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, resp.ProtoMajor, "openai_h2 必须协商到 HTTP/2") +} + // 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径: // 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。 func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { @@ -62,6 +103,6 @@ func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testi tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeOpenAIH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2) - require.NotNil(t, tr.TLSNextProto["h2"], "经代理的 openai_h2 也必须启用 http2 keepalive") + requireHTTP2Configured(t, tr, "经代理的 openai_h2 也必须启用 http2 keepalive") require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index ac0c064b20..3c7840dc47 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -228,16 +229,16 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") resp, err := svc.Do(req, "", accountID, 1) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) - require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent")) }) } } @@ -454,67 +455,67 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent")) }) t.Run("rejects an unsafe override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-Injected")) }) t.Run("rejects an override below the supported minimum", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) // Every entry sits above the pinned minimum, so a rejection here can only be // caused by the malformed semver and never by the version being too old. for _, version := range []string{ - "0.2.0115", - "0.2.115-alpha..1", + "0.2.0121", + "0.2.121-alpha..1", "0.3", "1", - "0.2.115+build.1", + "0.2.121+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -523,8 +524,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) } @@ -532,13 +533,13 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") applyGrokCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) }) } diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 169a01675a..0b786616a5 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -59,6 +59,9 @@ const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index const latestAPIKeyIPIndex = "idx_usage_logs_api_key_latest_ip" const usageLogsUpstreamModelMismatchIndexMigration = "195_add_usage_log_upstream_model_mismatch_index_notx.sql" const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_mismatch_created_at" +const usageLogsEffectiveModelIndexesMigration = "226_add_usage_log_effective_model_indexes_notx.sql" +const usageLogsEffectiveRequestedModelIndex = "idx_usage_logs_effective_requested_model_created" +const usageLogsEffectiveUpstreamModelIndex = "idx_usage_logs_effective_upstream_model_created" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -295,6 +298,13 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex) case usageLogsUpstreamModelMismatchIndexMigration: return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex) + case usageLogsEffectiveModelIndexesMigration: + for _, indexName := range []string{usageLogsEffectiveRequestedModelIndex, usageLogsEffectiveUpstreamModelIndex} { + if err := dropInvalidIndexIfPresent(ctx, db, indexName); err != nil { + return err + } + } + return nil default: return nil } diff --git a/backend/internal/repository/migrations_runner_notx_test.go b/backend/internal/repository/migrations_runner_notx_test.go index 55588bb043..8f9882e6d5 100644 --- a/backend/internal/repository/migrations_runner_notx_test.go +++ b/backend/internal/repository/migrations_runner_notx_test.go @@ -191,6 +191,47 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_c require.NoError(t, mock.ExpectationsWereMet()) } +func TestApplyMigrationsFS_NonTransactionalMigration_EffectiveModelIndexesDropInvalidIndexesBeforeRetry(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + prepareMigrationsBootstrapExpectations(mock) + mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1"). + WithArgs(usageLogsEffectiveModelIndexesMigration). + WillReturnError(sql.ErrNoRows) + for _, indexName := range []string{usageLogsEffectiveRequestedModelIndex, usageLogsEffectiveUpstreamModelIndex} { + mock.ExpectQuery("SELECT EXISTS \\("). + WithArgs(indexName). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS " + indexName). + WillReturnResult(sqlmock.NewResult(0, 0)) + } + mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)"). + WithArgs(usageLogsEffectiveModelIndexesMigration, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)"). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + fsys := fstest.MapFS{ + usageLogsEffectiveModelIndexesMigration: &fstest.MapFile{Data: []byte(` +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created + ON usage_logs ((COALESCE(NULLIF(BTRIM(requested_model), ''), model)), created_at DESC, id DESC); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created + ON usage_logs ((COALESCE(NULLIF(BTRIM(upstream_model), ''), model)), created_at DESC, id DESC); +`)}, + } + + err = applyMigrationsFS(context.Background(), db, fsys) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDuplicatePrecheck(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index acb270a3b3..e7a34ad6a0 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -46,9 +46,11 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie var tokenResp openai.TokenResponse + authUA, authOriginator := service.CodexCanonicalAuthIdentity() resp, err := client.R(). SetContext(ctx). - SetHeader("User-Agent", "codex-cli/0.91.0"). + SetHeader("User-Agent", authUA). + SetHeader("originator", authOriginator). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -94,9 +96,11 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre var tokenResp openai.TokenResponse + authUA, authOriginator := service.CodexCanonicalAuthIdentity() resp, err := client.R(). SetContext(ctx). - SetHeader("User-Agent", "codex-cli/0.91.0"). + SetHeader("User-Agent", authUA). + SetHeader("originator", authOriginator). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) diff --git a/backend/internal/repository/openai_oauth_service_test.go b/backend/internal/repository/openai_oauth_service_test.go index b43e2b52fc..d43b208d4e 100644 --- a/backend/internal/repository/openai_oauth_service_test.go +++ b/backend/internal/repository/openai_oauth_service_test.go @@ -10,6 +10,7 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -77,6 +78,17 @@ func (s *OpenAIOAuthServiceSuite) TestExchangeCode_DefaultRedirectURI() { w.WriteHeader(http.StatusBadRequest) return } + wantUA, wantOriginator := service.CodexCanonicalAuthIdentity() + if got := r.Header.Get("User-Agent"); got != wantUA { + errCh <- "user-agent mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } + if got := r.Header.Get("originator"); got != wantOriginator { + errCh <- "originator mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`) @@ -121,6 +133,17 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() { w.WriteHeader(http.StatusBadRequest) return } + wantUA, wantOriginator := service.CodexCanonicalAuthIdentity() + if got := r.Header.Get("User-Agent"); got != wantUA { + errCh <- "user-agent mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } + if got := r.Header.Get("originator"); got != wantOriginator { + errCh <- "originator mismatch" + w.WriteHeader(http.StatusBadRequest) + return + } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"access_token":"at2","refresh_token":"rt2","token_type":"bearer","expires_in":3600}`) diff --git a/backend/internal/repository/plugin_repo.go b/backend/internal/repository/plugin_repo.go new file mode 100644 index 0000000000..4c942a10d9 --- /dev/null +++ b/backend/internal/repository/plugin_repo.go @@ -0,0 +1,335 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type pluginRepository struct { + db *sql.DB +} + +func NewPluginRepository(db *sql.DB) service.PluginRepository { + return &pluginRepository{db: db} +} + +func (r *pluginRepository) List(ctx context.Context) ([]*service.PluginInstallation, error) { + rows, err := r.db.QueryContext(ctx, pluginSelectSQL+` ORDER BY installed_at DESC, id DESC`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + plugins := make([]*service.PluginInstallation, 0) + for rows.Next() { + plugin, scanErr := scanPlugin(rows) + if scanErr != nil { + return nil, scanErr + } + plugins = append(plugins, plugin) + } + if err := rows.Err(); err != nil { + return nil, err + } + for _, plugin := range plugins { + bindings, bindingErr := r.listBindings(ctx, plugin.ID) + if bindingErr != nil { + return nil, bindingErr + } + plugin.Bindings = bindings + } + return plugins, nil +} + +func (r *pluginRepository) GetByID(ctx context.Context, id int64) (*service.PluginInstallation, error) { + plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE id = $1`, id)) + if err != nil { + return nil, err + } + plugin.Bindings, err = r.listBindings(ctx, plugin.ID) + return plugin, err +} + +func (r *pluginRepository) GetByKey(ctx context.Context, key string) (*service.PluginInstallation, error) { + plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE plugin_key = $1`, key)) + if err != nil { + return nil, err + } + plugin.Bindings, err = r.listBindings(ctx, plugin.ID) + return plugin, err +} + +func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (*service.PluginInstallation, error) { + manifestJSON, err := json.Marshal(plugin.Manifest) + if err != nil { + return nil, fmt.Errorf("序列化插件清单: %w", err) + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + row := tx.QueryRowContext(ctx, ` + INSERT INTO sub2api_plugin_installations ( + plugin_key, name, version, description, author, manifest, artifact_data, + artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, last_error, installed_by, installed_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, '', $14, NOW(), NOW()) + ON CONFLICT (plugin_key) DO UPDATE SET + name = EXCLUDED.name, + version = EXCLUDED.version, + description = EXCLUDED.description, + author = EXCLUDED.author, + manifest = EXCLUDED.manifest, + artifact_data = EXCLUDED.artifact_data, + artifact_path = EXCLUDED.artifact_path, + install_path = EXCLUDED.install_path, + binary_path = EXCLUDED.binary_path, + binary_sha256 = EXCLUDED.binary_sha256, + signature_status = EXCLUDED.signature_status, + state = EXCLUDED.state, + last_error = '', + installed_by = EXCLUDED.installed_by, + installed_at = NOW(), + enabled_at = NULL, + updated_at = NOW() + WHERE sub2api_plugin_installations.state IN ('disabled', 'error', 'incompatible') + AND NOT EXISTS ( + SELECT 1 FROM sub2api_plugin_bindings b + WHERE b.plugin_id = sub2api_plugin_installations.id AND b.enabled = TRUE + ) + RETURNING id + `, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifestJSON, plugin.ArtifactData, + plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256, + plugin.SignatureStatus, plugin.State, plugin.InstalledBy) + var id int64 + if err := row.Scan(&id); err != nil { + if err == sql.ErrNoRows { + return nil, service.ErrPluginStateChanged + } + return nil, err + } + if err := replacePluginBindings(ctx, tx, id, bindings); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return r.GetByID(ctx, id) +} + +func (r *pluginRepository) GetArtifact(ctx context.Context, id int64) ([]byte, error) { + var artifact []byte + err := r.db.QueryRowContext(ctx, `SELECT artifact_data FROM sub2api_plugin_installations WHERE id = $1`, id).Scan(&artifact) + return artifact, err +} + +func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM sub2api_plugin_installations p + WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled') + AND NOT EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE) + `, id, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET state = 'starting', last_error = '', updated_at = NOW() + WHERE id = $1 AND binary_sha256 = $2 AND state = $3 AND state <> 'starting' + `, id, binarySHA256, expectedState) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations p + SET state = 'enabled', last_error = '', enabled_at = COALESCE(enabled_at, NOW()), updated_at = NOW() + WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.config_encrypted = $3 + AND EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE) + `, id, binarySHA256, configEncrypted) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW() + WHERE id = $1 AND binary_sha256 = $5 AND state = $6 + `, id, state, lastError, enabledAt, expectedBinarySHA256, expectedState) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET config_encrypted = $2, updated_at = NOW() + WHERE id = $1 AND binary_sha256 = $3 + `, id, encrypted, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) UpdateBindingsAndState( + ctx context.Context, + pluginID int64, + bindings []service.PluginBinding, + state string, + lastError string, + enabledAt *time.Time, + expectedState string, + expectedBinarySHA256 string, +) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW() + WHERE id = $1 AND ($5 = '' OR state = $5) AND binary_sha256 = $6 + `, pluginID, state, lastError, enabledAt, expectedState, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + if err := replacePluginBindings(ctx, tx, pluginID, bindings); err != nil { + return err + } + return tx.Commit() +} + +type pluginBindingExecutor interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func replacePluginBindings(ctx context.Context, executor pluginBindingExecutor, pluginID int64, bindings []service.PluginBinding) error { + if _, err := executor.ExecContext(ctx, `DELETE FROM sub2api_plugin_bindings WHERE plugin_id = $1`, pluginID); err != nil { + return err + } + for _, binding := range bindings { + if _, err := executor.ExecContext(ctx, ` + INSERT INTO sub2api_plugin_bindings ( + plugin_id, capability, platform, account_type, enabled, rollout_percent, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + `, pluginID, binding.Capability, binding.Platform, binding.AccountType, binding.Enabled, binding.RolloutPercent); err != nil { + return err + } + } + return nil +} + +const pluginSelectSQL = ` + SELECT id, plugin_key, name, version, description, author, manifest, + artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, config_encrypted, last_error, + installed_by, installed_at, enabled_at, updated_at + FROM sub2api_plugin_installations` + +type pluginScanner interface { + Scan(dest ...any) error +} + +func scanPlugin(scanner pluginScanner) (*service.PluginInstallation, error) { + plugin := &service.PluginInstallation{} + var manifestJSON []byte + if err := scanner.Scan( + &plugin.ID, &plugin.PluginKey, &plugin.Name, &plugin.Version, &plugin.Description, + &plugin.Author, &manifestJSON, &plugin.ArtifactPath, &plugin.InstallPath, + &plugin.BinaryPath, &plugin.BinarySHA256, &plugin.SignatureStatus, &plugin.State, + &plugin.ConfigEncrypted, &plugin.LastError, &plugin.InstalledBy, &plugin.InstalledAt, + &plugin.EnabledAt, &plugin.UpdatedAt, + ); err != nil { + return nil, err + } + if err := json.Unmarshal(manifestJSON, &plugin.Manifest); err != nil { + return nil, fmt.Errorf("解析插件清单: %w", err) + } + return plugin, nil +} + +func (r *pluginRepository) listBindings(ctx context.Context, pluginID int64) ([]service.PluginBinding, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, plugin_id, capability, platform, account_type, enabled, + rollout_percent, created_at, updated_at + FROM sub2api_plugin_bindings WHERE plugin_id = $1 ORDER BY id + `, pluginID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + bindings := make([]service.PluginBinding, 0) + for rows.Next() { + var binding service.PluginBinding + if err := rows.Scan(&binding.ID, &binding.PluginID, &binding.Capability, &binding.Platform, + &binding.AccountType, &binding.Enabled, &binding.RolloutPercent, + &binding.CreatedAt, &binding.UpdatedAt); err != nil { + return nil, err + } + bindings = append(bindings, binding) + } + return bindings, rows.Err() +} + +var _ service.PluginRepository = (*pluginRepository)(nil) diff --git a/backend/internal/repository/plugin_repo_integration_test.go b/backend/internal/repository/plugin_repo_integration_test.go new file mode 100644 index 0000000000..c81f5d3257 --- /dev/null +++ b/backend/internal/repository/plugin_repo_integration_test.go @@ -0,0 +1,70 @@ +//go:build integration + +package repository + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestPluginRepositoryLifecycleIsAtomicAndOptimistic(t *testing.T) { + ctx := context.Background() + repo := &pluginRepository{db: integrationDB} + pluginKey := "local.test.repository-" + strings.ToLower(time.Now().Format("150405.000000000")) + defer func() { + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key = $1`, pluginKey) + }() + + manifest := service.PluginManifest{SchemaVersion: 1, ID: pluginKey, Name: "测试插件", Version: "1.0.0"} + first := &service.PluginInstallation{ + PluginKey: pluginKey, Name: "测试插件", Version: "1.0.0", Manifest: manifest, + ArtifactData: []byte("first-package"), ArtifactPath: "/tmp/first.s2plugin", + InstallPath: "/tmp/first", BinaryPath: "/tmp/first/plugin", + BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted, + State: service.PluginStateDisabled, + } + bindings := []service.PluginBinding{{ + Capability: service.PluginCapabilityOpenAIOAuthOutbound, + Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth, + RolloutPercent: 100, + }} + installed, err := repo.Install(ctx, first, bindings) + require.NoError(t, err) + artifact, err := repo.GetArtifact(ctx, installed.ID) + require.NoError(t, err) + require.Equal(t, first.ArtifactData, artifact) + + require.NoError(t, repo.BeginEnable(ctx, installed.ID, first.BinarySHA256, service.PluginStateDisabled)) + bindings[0].Enabled = true + now := time.Now() + require.NoError(t, repo.UpdateBindingsAndState( + ctx, installed.ID, bindings, service.PluginStateEnabled, "", &now, + service.PluginStateStarting, first.BinarySHA256, + )) + + second := *first + second.Version = "1.1.0" + second.Manifest.Version = second.Version + second.BinarySHA256 = strings.Repeat("b", 64) + second.ArtifactData = []byte("second-package") + _, err = repo.Install(ctx, &second, bindings) + require.ErrorIs(t, err, service.ErrPluginStateChanged) + + bindings[0].Enabled = false + require.NoError(t, repo.UpdateBindingsAndState( + ctx, installed.ID, bindings, service.PluginStateDisabled, "", nil, "", first.BinarySHA256, + )) + replaced, err := repo.Install(ctx, &second, bindings) + require.NoError(t, err) + require.Equal(t, installed.ID, replaced.ID) + + err = repo.Delete(ctx, replaced.ID, first.BinarySHA256) + require.True(t, errors.Is(err, service.ErrPluginStateChanged)) + require.NoError(t, repo.Delete(ctx, replaced.ID, second.BinarySHA256)) +} diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 245eaade81..6088edfcc6 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -31,11 +31,24 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { if insecure { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } + // 构建探测 URL 列表:配置存在时覆盖内置默认列表。 + var configuredTargets []configuredProbeTarget + if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 { + configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) + for _, u := range cfg.Security.ProxyProbe.URLs { + configuredTargets = append(configuredTargets, configuredProbeTarget{ + url: u.URL, + parser: u.Parser, + }) + } + } + return &proxyProbeService{ - insecureSkipVerify: insecure, - allowPrivateHosts: allowPrivate, - validateResolvedIP: validateResolvedIP, - maxResponseBytes: maxResponseBytes, + insecureSkipVerify: insecure, + allowPrivateHosts: allowPrivate, + validateResolvedIP: validateResolvedIP, + maxResponseBytes: maxResponseBytes, + configuredProbeURLs: configuredTargets, } } @@ -44,21 +57,27 @@ const ( defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) -// probeURLs 按优先级排列的探测 URL 列表 -// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选 +// probeURLs 按优先级排列的内置探测 URL 列表。 +// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。 var probeURLs = []struct { url string - parser string // "ip-api" or "ipify" + parser string }{ {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, {"http://api64.ipify.org?format=json", "ipify"}, } +type configuredProbeTarget struct { + url string + parser string +} + type proxyProbeService struct { - insecureSkipVerify bool - allowPrivateHosts bool - validateResolvedIP bool - maxResponseBytes int64 + insecureSkipVerify bool + allowPrivateHosts bool + validateResolvedIP bool + maxResponseBytes int64 + configuredProbeURLs []configuredProbeTarget } func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) { @@ -74,6 +93,17 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s } var lastErr error + if len(s.configuredProbeURLs) > 0 { + for _, probe := range s.configuredProbeURLs { + exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) + if err == nil { + return exitInfo, latencyMs, nil + } + lastErr = err + } + return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr) + } + for _, probe := range probeURLs { exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) if err == nil { @@ -121,6 +151,8 @@ func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Clien return s.parseIPAPI(body, latencyMs) case "ipify": return s.parseIPify(body, latencyMs) + case "chatgpt-trace": + return s.parseChatGPTTrace(body, latencyMs) default: return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser) } @@ -179,3 +211,35 @@ func (s *proxyProbeService) parseIPify(body []byte, latencyMs int64) (*service.P IP: result.IP, }, latencyMs, nil } + +// parseChatGPTTrace 解析 Cloudflare trace 端点(如 chatgpt.com/cdn-cgi/trace)的纯文本响应。 +// 响应按行给出键值对,其中 ip= 为出口 IP,loc= 为国家代码。 +func (s *proxyProbeService) parseChatGPTTrace(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { + var ip, loc string + for _, line := range strings.Split(string(body), "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + switch key { + case "ip": + ip = strings.TrimSpace(value) + case "loc": + loc = strings.TrimSpace(value) + } + } + if ip == "" { + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, latencyMs, fmt.Errorf("chatgpt-trace: no ip= found in response (body: %s)", preview) + } + info := &service.ProxyExitInfo{ + IP: ip, + } + if loc != "" { + info.CountryCode = loc + } + return info, latencyMs, nil +} diff --git a/backend/internal/repository/proxy_probe_service_test.go b/backend/internal/repository/proxy_probe_service_test.go index c778f6c1eb..45c342cb4b 100644 --- a/backend/internal/repository/proxy_probe_service_test.go +++ b/backend/internal/repository/proxy_probe_service_test.go @@ -166,6 +166,22 @@ func (s *ProxyProbeServiceSuite) TestParseIPify_NoIP() { require.ErrorContains(s.T(), err, "no IP found") } +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_Success() { + body := []byte("fl=abc\nh=chatgpt.com\nip=203.0.113.5\nts=1700000000\nloc=US\ntz=UTC\n") + info, latencyMs, err := s.prober.parseChatGPTTrace(body, 320) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(320), latencyMs) + require.Equal(s.T(), "203.0.113.5", info.IP) + require.Equal(s.T(), "US", info.CountryCode) +} + +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_NoIP() { + body := []byte("fl=abc\nh=chatgpt.com\nloc=US\n") + _, _, err := s.prober.parseChatGPTTrace(body, 100) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "chatgpt-trace: no ip= found") +} + func TestProxyProbeServiceSuite(t *testing.T) { suite.Run(t, new(ProxyProbeServiceSuite)) } diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index b03e4e6637..b53c51d43f 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -1000,6 +1000,8 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "openai_ws_force_http", "openai_responses_mode", "openai_responses_supported", + "codex_fingerprint_mode", + "codex_fingerprint_seed", "codex_5h_used_percent", "codex_7d_used_percent", "codex_5h_reset_at", diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index d0373f16c7..354cf6d8aa 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -309,6 +309,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) { "openai_ws_force_http": true, "openai_responses_mode": "force_chat_completions", "openai_responses_supported": false, + "codex_fingerprint_mode": "session", + "codex_fingerprint_seed": "11111111-1111-4111-8111-111111111111", "mixed_scheduling": true, "unused_large_field": "drop-me", }, @@ -321,6 +323,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) { require.Equal(t, true, got.Extra["openai_ws_force_http"]) require.Equal(t, "force_chat_completions", got.Extra["openai_responses_mode"]) require.Equal(t, false, got.Extra["openai_responses_supported"]) + require.Equal(t, "session", got.Extra["codex_fingerprint_mode"]) + require.Equal(t, "11111111-1111-4111-8111-111111111111", got.Extra["codex_fingerprint_seed"]) require.Equal(t, true, got.Extra["mixed_scheduling"]) require.Nil(t, got.Extra["unused_large_field"]) } diff --git a/backend/internal/repository/temp_unsched_cache.go b/backend/internal/repository/temp_unsched_cache.go index 55115eb835..42c2fd648c 100644 --- a/backend/internal/repository/temp_unsched_cache.go +++ b/backend/internal/repository/temp_unsched_cache.go @@ -12,6 +12,30 @@ import ( const tempUnschedPrefix = "temp_unsched:account:" +const openAIAPIKeyHealthFailurePrefix = "openai_apikey_health:" + +var openAIAPIKeyHealthFailureScript = redis.NewScript(` + local key = KEYS[1] + local sequence_key = key .. ':sequence' + local now = redis.call('TIME') + local now_ms = (tonumber(now[1]) * 1000) + math.floor(tonumber(now[2]) / 1000) + local window_ms = tonumber(ARGV[1]) * 60 * 1000 + local threshold = tonumber(ARGV[2]) + local sequence = redis.call('INCR', sequence_key) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms) + redis.call('ZADD', key, now_ms, tostring(now_ms) .. ':' .. tostring(sequence)) + local count = redis.call('ZCARD', key) + local ttl = math.max(60, (tonumber(ARGV[1]) + 1) * 60) + redis.call('EXPIRE', key, ttl) + redis.call('EXPIRE', sequence_key, ttl) + if count >= threshold then + redis.call('DEL', key, sequence_key) + return {count, 1} + end + return {count, 0} +`) + var tempUnschedSetScript = redis.NewScript(` local key = KEYS[1] local new_until = tonumber(ARGV[1]) @@ -89,3 +113,31 @@ func (c *tempUnschedCache) DeleteTempUnsched(ctx context.Context, accountID int6 key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID) return c.rdb.Del(ctx, key).Err() } + +func (c *tempUnschedCache) openAIAPIKeyHealthKey(accountID int64) string { + // The hash tag keeps the rolling window and sequence key in one Redis + // Cluster slot even though the Lua script derives the latter dynamically. + return fmt.Sprintf("%s{%d}:failures", openAIAPIKeyHealthFailurePrefix, accountID) +} + +func (c *tempUnschedCache) RecordOpenAIAPIKeyHealthFailure(ctx context.Context, accountID int64, windowMinutes, threshold int) (int64, bool, error) { + if windowMinutes < 1 { + windowMinutes = 1 + } + if threshold < 1 { + threshold = 1 + } + result, err := openAIAPIKeyHealthFailureScript.Run(ctx, c.rdb, []string{c.openAIAPIKeyHealthKey(accountID)}, windowMinutes, threshold).Slice() + if err != nil { + return 0, false, fmt.Errorf("record OpenAI API key health failure: %w", err) + } + if len(result) != 2 { + return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result length %d", len(result)) + } + count, countOK := result[0].(int64) + tripped, trippedOK := result[1].(int64) + if !countOK || !trippedOK { + return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result types %T/%T", result[0], result[1]) + } + return count, tripped == 1, nil +} diff --git a/backend/internal/repository/temp_unsched_cache_health_test.go b/backend/internal/repository/temp_unsched_cache_health_test.go new file mode 100644 index 0000000000..1b8a1f10c6 --- /dev/null +++ b/backend/internal/repository/temp_unsched_cache_health_test.go @@ -0,0 +1,50 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestOpenAIAPIKeyHealthCacheTripsWithinRollingWindow(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + store, ok := NewTempUnschedCache(client).(service.OpenAIAPIKeyHealthCache) + require.True(t, ok) + + ctx := context.Background() + for attempt := 1; attempt <= 3; attempt++ { + count, tripped, err := store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3) + require.NoError(t, err) + require.EqualValues(t, attempt, count) + require.Equal(t, attempt == 3, tripped) + } +} + +func TestOpenAIAPIKeyHealthCacheDropsFailuresOutsideRollingWindow(t *testing.T) { + server := miniredis.RunT(t) + now := time.Unix(1_700_000_000, 0) + server.SetTime(now) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + store, ok := NewTempUnschedCache(client).(service.OpenAIAPIKeyHealthCache) + require.True(t, ok) + + ctx := context.Background() + count, tripped, err := store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3) + require.NoError(t, err) + require.EqualValues(t, 1, count) + require.False(t, tripped) + + server.SetTime(now.Add(61 * time.Second)) + count, tripped, err = store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3) + require.NoError(t, err) + require.EqualValues(t, 1, count) + require.False(t, tripped) +} diff --git a/backend/internal/repository/usage_cleanup_repo.go b/backend/internal/repository/usage_cleanup_repo.go index 1a25696e4c..f8aa67477f 100644 --- a/backend/internal/repository/usage_cleanup_repo.go +++ b/backend/internal/repository/usage_cleanup_repo.go @@ -291,6 +291,9 @@ func (r *usageCleanupRepository) DeleteUsageLogsBatch(ctx context.Context, filte return 0, fmt.Errorf("cleanup filters missing time range") } args = append(args, limit) + if db, ok := r.sql.(*sql.DB); ok { + return r.deleteUsageLogsBatchWithRollupInvalidation(ctx, db, whereClause, args) + } query := fmt.Sprintf(` WITH target AS ( SELECT id @@ -301,7 +304,7 @@ func (r *usageCleanupRepository) DeleteUsageLogsBatch(ctx context.Context, filte ) DELETE FROM usage_logs WHERE id IN (SELECT id FROM target) - RETURNING id + RETURNING created_at `, whereClause, len(args)) rows, err := r.sql.QueryContext(ctx, query, args...) @@ -320,6 +323,68 @@ func (r *usageCleanupRepository) DeleteUsageLogsBatch(ctx context.Context, filte return deleted, nil } +func (r *usageCleanupRepository) deleteUsageLogsBatchWithRollupInvalidation(ctx context.Context, db *sql.DB, whereClause string, args []any) (int64, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + rollback := func(err error) (int64, error) { + _ = tx.Rollback() + return 0, err + } + + if err := lockGroupUsageRollupState(ctx, tx); err != nil { + return rollback(err) + } + query := fmt.Sprintf(` + WITH target AS ( + SELECT id + FROM usage_logs + WHERE %s + ORDER BY created_at ASC, id ASC + LIMIT $%d + ) + DELETE FROM usage_logs + WHERE id IN (SELECT id FROM target) + RETURNING created_at + `, whereClause, len(args)) + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return rollback(err) + } + + var deleted int64 + var earliestDeletedAt time.Time + for rows.Next() { + var deletedAt time.Time + if err := rows.Scan(&deletedAt); err != nil { + _ = rows.Close() + return rollback(err) + } + deleted++ + if earliestDeletedAt.IsZero() || deletedAt.Before(earliestDeletedAt) { + earliestDeletedAt = deletedAt + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return rollback(err) + } + if err := rows.Close(); err != nil { + return rollback(err) + } + + if deleted > 0 { + if err := invalidateGroupUsageRollupsAt(ctx, tx, earliestDeletedAt); err != nil { + return rollback(err) + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return deleted, nil +} + func buildUsageCleanupWhere(filters service.UsageCleanupFilters) (string, []any) { conditions := make([]string, 0, 8) args := make([]any, 0, 8) diff --git a/backend/internal/repository/usage_cleanup_repo_test.go b/backend/internal/repository/usage_cleanup_repo_test.go index 1ac7cca569..b147d03a4f 100644 --- a/backend/internal/repository/usage_cleanup_repo_test.go +++ b/backend/internal/repository/usage_cleanup_repo_test.go @@ -21,6 +21,11 @@ func newSQLMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock) { return db, mock } +func setUsageCleanupRollupTestTimezone(t *testing.T) { + t.Helper() + useGroupUsageRepositoryTestTimezone(t, "Asia/Shanghai") +} + func TestNewUsageCleanupRepository(t *testing.T) { db, _ := newSQLMock(t) repo := NewUsageCleanupRepository(nil, db) @@ -398,6 +403,7 @@ func TestUsageCleanupRepositoryDeleteUsageLogsBatchMissingRange(t *testing.T) { } func TestUsageCleanupRepositoryDeleteUsageLogsBatch(t *testing.T) { + setUsageCleanupRollupTestTimezone(t) db, mock := newSQLMock(t) repo := &usageCleanupRepository{sql: db} @@ -412,9 +418,16 @@ func TestUsageCleanupRepositoryDeleteUsageLogsBatch(t *testing.T) { Model: &model, } + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) mock.ExpectQuery("DELETE FROM usage_logs"). WithArgs(start, end, userID, "gpt-4", 2). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(1)).AddRow(int64(2))) + WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(start.Add(time.Hour)).AddRow(start.Add(2 * time.Hour))) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(start.Add(time.Hour), "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() deleted, err := repo.DeleteUsageLogsBatch(context.Background(), filters, 2) require.NoError(t, err) @@ -422,6 +435,62 @@ func TestUsageCleanupRepositoryDeleteUsageLogsBatch(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +func TestUsageCleanupRepositoryDeleteUsageLogsBatchAtomicallyInvalidatesGroupRollups(t *testing.T) { + setUsageCleanupRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := &usageCleanupRepository{sql: db} + + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + firstDeletedAt := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC) + secondDeletedAt := firstDeletedAt.Add(time.Hour) + filters := service.UsageCleanupFilters{StartTime: start, EndTime: end} + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`(?s)DELETE FROM usage_logs.*RETURNING created_at`). + WithArgs(start, end, 2). + WillReturnRows(sqlmock.NewRows([]string{"created_at"}). + AddRow(firstDeletedAt). + AddRow(secondDeletedAt)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(firstDeletedAt, "Asia/Shanghai"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + deleted, err := repo.DeleteUsageLogsBatch(context.Background(), filters, 2) + require.NoError(t, err) + require.Equal(t, int64(2), deleted) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUsageCleanupRepositoryDeleteUsageLogsBatchRollsBackWhenInvalidationFails(t *testing.T) { + setUsageCleanupRollupTestTimezone(t) + db, mock := newSQLMock(t) + repo := &usageCleanupRepository{sql: db} + + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + deletedAt := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC) + filters := service.UsageCleanupFilters{StartTime: start, EndTime: end} + + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + mock.ExpectQuery(`(?s)DELETE FROM usage_logs.*RETURNING created_at`). + WithArgs(start, end, 1). + WillReturnRows(sqlmock.NewRows([]string{"created_at"}).AddRow(deletedAt)) + mock.ExpectExec(`UPDATE usage_group_rollup_state`). + WithArgs(deletedAt, "Asia/Shanghai"). + WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() + + _, err := repo.DeleteUsageLogsBatch(context.Background(), filters, 1) + require.ErrorIs(t, err, sql.ErrConnDone) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestUsageCleanupRepositoryDeleteUsageLogsBatchQueryError(t *testing.T) { db, mock := newSQLMock(t) repo := &usageCleanupRepository{sql: db} @@ -430,9 +499,13 @@ func TestUsageCleanupRepositoryDeleteUsageLogsBatchQueryError(t *testing.T) { end := start.Add(24 * time.Hour) filters := service.UsageCleanupFilters{StartTime: start, EndTime: end} + mock.ExpectBegin() + mock.ExpectQuery(`SELECT id FROM usage_group_rollup_state.*FOR UPDATE`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) mock.ExpectQuery("DELETE FROM usage_logs"). WithArgs(start, end, 5). WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() _, err := repo.DeleteUsageLogsBatch(context.Background(), filters, 5) require.Error(t, err) diff --git a/backend/internal/repository/usage_log_repo_group_summary_test.go b/backend/internal/repository/usage_log_repo_group_summary_test.go new file mode 100644 index 0000000000..f67facb4b9 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_group_summary_test.go @@ -0,0 +1,33 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) { + db, mock := newSQLMock(t) + repo := newUsageLogRepositoryWithSQL(nil, db) + useGroupUsageRepositoryTestTimezone(t, "America/New_York") + todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC) + + mock.ExpectQuery(`(?s)usage_group_rollup_state.*usage_group_daily_rollups.*created_at >= state\.tail_start`). + WithArgs(todayStart, yesterdayStart, "America/New_York", "2026-03-09", "2026-03-08"). + WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"}). + AddRow(int64(7), 12.5, 1.25, 2.5)) + + result, err := repo.GetAllGroupUsageSummary(context.Background(), todayStart) + require.NoError(t, err) + require.Equal(t, int64(7), result[0].GroupID) + require.InDelta(t, 12.5, result[0].TotalCost, 0.0000001) + require.InDelta(t, 1.25, result[0].TodayCost, 0.0000001) + require.InDelta(t, 2.5, result[0].YesterdayCost, 0.0000001) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index a2b82dfa60..a67c93c345 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -505,33 +505,33 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestedModelSource(t *testing.T) ModelFilterSource: usagestats.ModelSourceRequested, } - mock.ExpectQuery("FROM usage_logs\\s+WHERE COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$1"). + mock.ExpectQuery("(?s)FROM usage_logs\\s+WHERE COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$1.*GROUP BY GROUPING SETS"). WithArgs("gpt-5"). WillReturnRows(sqlmock.NewRows([]string{ - "total_requests", - "total_input_tokens", - "total_output_tokens", - "total_cache_tokens", - "total_cache_creation_tokens", - "total_cache_read_tokens", - "total_cost", - "total_actual_cost", - "total_account_cost", + "inbound_grouped", + "upstream_grouped", + "inbound_endpoint", + "upstream_endpoint", + "requests", + "input_tokens", + "output_tokens", + "cache_creation_tokens", + "cache_read_tokens", + "cost", + "actual_cost", + "account_cost", "avg_duration_ms", - }).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT CONCAT\\("). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + }). + AddRow(1, 1, nil, nil, int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0). + AddRow(0, 1, "/v1/responses", nil, int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0). + AddRow(1, 0, nil, "/v1/responses", int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0). + AddRow(0, 0, "/v1/responses", "/v1/responses", int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) stats, err := repo.GetStatsWithFilters(context.Background(), filters) require.NoError(t, err) require.Equal(t, int64(1), stats.TotalRequests) + require.Equal(t, "/v1/responses", stats.Endpoints[0].Endpoint) + require.Equal(t, "/v1/responses -> /v1/responses", stats.EndpointPaths[0].Endpoint) require.NoError(t, mock.ExpectationsWereMet()) } @@ -546,29 +546,23 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T) Stream: &stream, } - mock.ExpectQuery("FROM usage_logs\\s+WHERE \\(request_type = \\$1 OR \\(request_type = 0 AND stream = FALSE AND openai_ws_mode = FALSE\\)\\)"). + mock.ExpectQuery("(?s)FROM usage_logs\\s+WHERE \\(request_type = \\$1 OR \\(request_type = 0 AND stream = FALSE AND openai_ws_mode = FALSE\\)\\).*GROUP BY GROUPING SETS"). WithArgs(requestType). WillReturnRows(sqlmock.NewRows([]string{ - "total_requests", - "total_input_tokens", - "total_output_tokens", - "total_cache_tokens", - "total_cache_creation_tokens", - "total_cache_read_tokens", - "total_cost", - "total_actual_cost", - "total_account_cost", + "inbound_grouped", + "upstream_grouped", + "inbound_endpoint", + "upstream_endpoint", + "requests", + "input_tokens", + "output_tokens", + "cache_creation_tokens", + "cache_read_tokens", + "cost", + "actual_cost", + "account_cost", "avg_duration_ms", - }).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT CONCAT\\("). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + }).AddRow(1, 1, nil, nil, int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) stats, err := repo.GetStatsWithFilters(context.Background(), filters) require.NoError(t, err) @@ -689,19 +683,12 @@ func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testin // No AccountID filter set - TotalAccountCost should still be returned filters := usagestats.UsageLogFilters{} - mock.ExpectQuery("FROM usage_logs"). + mock.ExpectQuery("(?s)FROM usage_logs.*GROUP BY GROUPING SETS"). WillReturnRows(sqlmock.NewRows([]string{ - "total_requests", "total_input_tokens", "total_output_tokens", - "total_cache_tokens", "total_cache_creation_tokens", "total_cache_read_tokens", - "total_cost", "total_actual_cost", - "total_account_cost", "avg_duration_ms", - }).AddRow(int64(50), int64(1000), int64(2000), int64(100), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0)) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\)"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\)"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT CONCAT\\("). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + "inbound_grouped", "upstream_grouped", "inbound_endpoint", "upstream_endpoint", + "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", + "cost", "actual_cost", "account_cost", "avg_duration_ms", + }).AddRow(1, 1, nil, nil, int64(50), int64(1000), int64(2000), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0)) stats, err := repo.GetStatsWithFilters(context.Background(), filters) require.NoError(t, err) @@ -793,7 +780,7 @@ func (s usageLogScannerStub) Scan(dest ...any) error { } for i := range dest { dv := reflect.ValueOf(dest[i]) - if dv.Kind() != reflect.Ptr { + if dv.Kind() != reflect.Pointer { return fmt.Errorf("dest[%d] is not pointer", i) } dv.Elem().Set(reflect.ValueOf(s.values[i])) diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go index 53d60b7f4a..9385900a71 100644 --- a/backend/internal/repository/usage_log_repo_stats.go +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -3,9 +3,9 @@ package repository import ( "context" "database/sql" - "errors" "fmt" "os" + "sort" "strings" "time" @@ -14,7 +14,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/lib/pq" - "golang.org/x/sync/errgroup" ) // GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation @@ -696,108 +695,131 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us } query := fmt.Sprintf(` + WITH scoped AS ( + SELECT + COALESCE(NULLIF(TRIM(inbound_endpoint), ''), 'unknown') AS inbound_endpoint, + COALESCE(NULLIF(TRIM(upstream_endpoint), ''), 'unknown') AS upstream_endpoint, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_cost, + actual_cost, + COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) AS account_cost, + duration_ms + FROM usage_logs + %s + ) SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost, - COALESCE(AVG(duration_ms), 0) as avg_duration_ms - FROM usage_logs - %s + GROUPING(inbound_endpoint) AS inbound_grouped, + GROUPING(upstream_endpoint) AS upstream_grouped, + inbound_endpoint, + upstream_endpoint, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(total_cost), 0) AS cost, + COALESCE(SUM(actual_cost), 0) AS actual_cost, + COALESCE(SUM(account_cost), 0) AS account_cost, + COALESCE(AVG(duration_ms), 0) AS avg_duration_ms + FROM scoped + GROUP BY GROUPING SETS ( + (), + (inbound_endpoint), + (upstream_endpoint), + (inbound_endpoint, upstream_endpoint) + ) `, buildWhere(conditions)) stats := &UsageStats{} var totalAccountCost float64 - - start := time.Unix(0, 0).UTC() - if filters.StartTime != nil { - start = *filters.StartTime - } - end := time.Now().UTC() - if filters.EndTime != nil { - end = *filters.EndTime + useAccountCostForEndpoint := filters.AccountID > 0 && filters.UserID == 0 && filters.APIKeyID == 0 + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err } + defer func() { _ = rows.Close() }() - var endpoints, upstreamEndpoints, endpointPaths []EndpointStat - - // 汇总查询:失败即致命。 - runSummary := func(c context.Context) error { - return scanSingleRow( - c, r.sql, query, args, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &totalAccountCost, - &stats.AverageDurationMs, + for rows.Next() { + var ( + inboundGrouped, upstreamGrouped int + inboundEndpoint, upstreamEndpoint sql.NullString + requests, inputTokens, outputTokens, cacheCreationTokens, cacheReads int64 + cost, actualCost, accountCost, averageDurationMs float64 ) - } - // endpoint 明细:best-effort(失败 log + 返空),不致命。 - runEndpoints := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} + if err := rows.Scan( + &inboundGrouped, + &upstreamGrouped, + &inboundEndpoint, + &upstreamEndpoint, + &requests, + &inputTokens, + &outputTokens, + &cacheCreationTokens, + &cacheReads, + &cost, + &actualCost, + &accountCost, + &averageDurationMs, + ); err != nil { + return nil, err } - endpoints = res - } - runUpstream := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} + + totalTokens := inputTokens + outputTokens + cacheCreationTokens + cacheReads + endpointActualCost := actualCost + if useAccountCostForEndpoint { + endpointActualCost = accountCost } - upstreamEndpoints = res - } - runPaths := func(c context.Context) { - res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} + + switch { + case inboundGrouped == 1 && upstreamGrouped == 1: + stats.TotalRequests = requests + stats.TotalInputTokens = inputTokens + stats.TotalOutputTokens = outputTokens + stats.TotalCacheCreationTokens = cacheCreationTokens + stats.TotalCacheReadTokens = cacheReads + stats.TotalCacheTokens = cacheCreationTokens + cacheReads + stats.TotalCost = cost + stats.TotalActualCost = actualCost + totalAccountCost = accountCost + stats.AverageDurationMs = averageDurationMs + case inboundGrouped == 0 && upstreamGrouped == 1: + stats.Endpoints = append(stats.Endpoints, EndpointStat{ + Endpoint: inboundEndpoint.String, Requests: requests, TotalTokens: totalTokens, + Cost: cost, ActualCost: endpointActualCost, + }) + case inboundGrouped == 1 && upstreamGrouped == 0: + stats.UpstreamEndpoints = append(stats.UpstreamEndpoints, EndpointStat{ + Endpoint: upstreamEndpoint.String, Requests: requests, TotalTokens: totalTokens, + Cost: cost, ActualCost: endpointActualCost, + }) + case inboundGrouped == 0 && upstreamGrouped == 0: + stats.EndpointPaths = append(stats.EndpointPaths, EndpointStat{ + Endpoint: inboundEndpoint.String + " -> " + upstreamEndpoint.String, + Requests: requests, TotalTokens: totalTokens, Cost: cost, ActualCost: endpointActualCost, + }) } - endpointPaths = res + } + if err := rows.Err(); err != nil { + return nil, err } - if r.db != nil { - // 生产路径:r.sql 是 *sql.DB 连接池,可并发。4 条查询并行,延迟取最大值。 - g, gctx := errgroup.WithContext(ctx) - g.Go(func() error { return runSummary(gctx) }) - g.Go(func() error { runEndpoints(gctx); return nil }) - g.Go(func() error { runUpstream(gctx); return nil }) - g.Go(func() error { runPaths(gctx); return nil }) - if err := g.Wait(); err != nil { - return nil, err - } - } else { - // 事务路径(ent.Tx 不能并发查询):顺序执行,行为与重构前一致。 - if err := runSummary(ctx); err != nil { - return nil, err - } - runEndpoints(ctx) - runUpstream(ctx) - runPaths(ctx) + sortEndpointStats := func(values []EndpointStat) { + sort.Slice(values, func(i, j int) bool { + if values[i].Requests != values[j].Requests { + return values[i].Requests > values[j].Requests + } + return values[i].Endpoint < values[j].Endpoint + }) } + sortEndpointStats(stats.Endpoints) + sortEndpointStats(stats.UpstreamEndpoints) + sortEndpointStats(stats.EndpointPaths) stats.TotalAccountCost = &totalAccountCost stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - stats.Endpoints = endpoints - stats.UpstreamEndpoints = upstreamEndpoints - stats.EndpointPaths = endpointPaths return stats, nil } @@ -882,78 +904,6 @@ func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Con return results, nil } -func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { - actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" - if accountID > 0 && userID == 0 && apiKeyID == 0 { - actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" - } - - query := fmt.Sprintf(` - SELECT - CONCAT( - COALESCE(NULLIF(TRIM(inbound_endpoint), ''), 'unknown'), - ' -> ', - COALESCE(NULLIF(TRIM(upstream_endpoint), ''), 'unknown') - ) AS endpoint, - COUNT(*) AS requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - %s - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - `, actualCostExpr) - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) - args = append(args, groupID) - } - query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") - query += " GROUP BY endpoint ORDER BY requests DESC" - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]EndpointStat, 0) - for rows.Next() { - var row EndpointStat - if err := rows.Scan(&row.Endpoint, &row.Requests, &row.TotalTokens, &row.Cost, &row.ActualCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - // GetEndpointStatsWithFilters returns inbound endpoint statistics with optional filters. func (r *usageLogRepository) GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go index e9025b1bd1..754ea32a97 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -43,6 +43,15 @@ func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) { }) require.NoError(t, err) require.Equal(t, int64(1), stats.TotalRequests) + require.Equal(t, []usagestats.EndpointStat{{ + Endpoint: "unknown", Requests: 1, TotalTokens: 2, + }}, stats.Endpoints) + require.Equal(t, []usagestats.EndpointStat{{ + Endpoint: "unknown", Requests: 1, TotalTokens: 2, + }}, stats.UpstreamEndpoints) + require.Equal(t, []usagestats.EndpointStat{{ + Endpoint: "unknown -> unknown", Requests: 1, TotalTokens: 2, + }}, stats.EndpointPaths) trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{ UserID: user.ID, UpstreamModelMismatch: &trueValue, @@ -53,24 +62,45 @@ func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) { _, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off") require.NoError(t, err) - rows, err := tx.QueryContext(ctx, ` + assertPlanUsesIndex := func(query, indexName string, args ...any) { + rows, queryErr := tx.QueryContext(ctx, query, args...) + require.NoError(t, queryErr) + var planLines []string + for rows.Next() { + var line string + require.NoError(t, rows.Scan(&line)) + planLines = append(planLines, line) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Contains(t, strings.Join(planLines, "\n"), indexName) + } + assertPlanUsesIndex(` EXPLAIN (COSTS OFF) SELECT id FROM usage_logs WHERE upstream_model_mismatch IS TRUE ORDER BY created_at DESC, id DESC LIMIT 100 -`) - require.NoError(t, err) - defer func() { require.NoError(t, rows.Close()) }() - var planLines []string - for rows.Next() { - var line string - require.NoError(t, rows.Scan(&line)) - planLines = append(planLines, line) - } - require.NoError(t, rows.Err()) - require.Contains(t, strings.Join(planLines, "\n"), usageLogsUpstreamModelMismatchIndex) +`, usageLogsUpstreamModelMismatchIndex) + assertPlanUsesIndex(` +EXPLAIN (COSTS OFF) +SELECT id +FROM usage_logs +WHERE COALESCE(NULLIF(TRIM(requested_model), ''), model) = $1 + AND created_at >= $2 AND created_at < $3 +ORDER BY created_at DESC, id DESC +LIMIT 100 +`, usageLogsEffectiveRequestedModelIndex, "gpt-5.5", start, end) + assertPlanUsesIndex(` +EXPLAIN (COSTS OFF) +SELECT id +FROM usage_logs +WHERE COALESCE(NULLIF(TRIM(upstream_model), ''), model) = $1 + AND created_at >= $2 AND created_at < $3 +ORDER BY created_at DESC, id DESC +LIMIT 100 +`, usageLogsEffectiveUpstreamModelIndex, "gpt-5.5", start, end) } func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 6d8bd79eaa..3ed3aa51b1 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -715,39 +715,9 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim return results, nil } -// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group. -// todayStart is the start-of-day in the caller's timezone (UTC-based). -// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation. -// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s) -// or a materialized view / pre-aggregation table for cumulative costs. +// GetAllGroupUsageSummary 返回所有分组在服务端配置时区内的今日、昨日与当前保留记录累计金额。 func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { - query := ` - SELECT - g.id AS group_id, - COALESCE(SUM(ul.actual_cost), 0) AS total_cost, - COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost - FROM groups g - LEFT JOIN usage_logs ul ON ul.group_id = g.id - GROUP BY g.id - ` - - rows, err := r.sql.QueryContext(ctx, query, todayStart) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - var results []usagestats.GroupUsageSummary - for rows.Next() { - var row usagestats.GroupUsageSummary - if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil + return r.getAllGroupUsageSummaryFromRollups(ctx, todayStart) } // resolveModelDimensionExpression maps model source type to a safe SQL expression. diff --git a/backend/internal/repository/user_platform_quota_repo_integration_test.go b/backend/internal/repository/user_platform_quota_repo_integration_test.go index 4a8d1b5896..1bd4b08786 100644 --- a/backend/internal/repository/user_platform_quota_repo_integration_test.go +++ b/backend/internal/repository/user_platform_quota_repo_integration_test.go @@ -98,6 +98,35 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_GrokAllowed(t *testing.T) require.InDelta(t, 9.0, *rec.DailyLimitUSD, 1e-9) } +// TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed 回归迁移 224: +// kimi/zhipu/deepseek 平台必须能写入 user_platform_quotas(CHECK 约束已含国产供应商)。 +// 历史 bug:三个平台不在约束内 → 注册预填充 8 平台默认配额时整条多行 INSERT 中止 → +// fail-open 吞错 → 新用户拿到零条配额记录(缺失配额行 = 无限额)。 +func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + + userID := mustCreateUserForQuota(t, client) + repo := NewUserPlatformQuotaRepository(client) + + daily := 12.0 + records := []UserPlatformQuotaRecord{ + {UserID: userID, Platform: "kimi", DailyLimitUSD: &daily}, + {UserID: userID, Platform: "zhipu"}, + {UserID: userID, Platform: "deepseek"}, + } + require.NoError(t, repo.BulkInsertInitial(txCtx, records), + "kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)") + + for _, platform := range []string{"kimi", "zhipu", "deepseek"} { + rec, err := repo.GetByUserPlatform(txCtx, userID, platform) + require.NoError(t, err) + require.NotNil(t, rec, "%s 配额行应已写入", platform) + } +} + func TestUserPlatformQuotaRepository_GetByUserPlatform(t *testing.T) { ctx := context.Background() tx := testEntTx(t) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index def7462c12..3d02d6fbff 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -70,23 +70,30 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard // 统一使用 ent 的事务:保证用户与允许分组的更新原子化, // 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。 - tx, err := r.client.Tx(ctx) - if err != nil && !errors.Is(err, dbent.ErrTxStarted) { - return err - } - + // + // 注意:ent 的 Client.Tx 不感知上下文中的事务(只检查 driver 类型), + // 因此必须显式检查 TxFromContext:当调用方已开启外部事务(如注册时的 + // “建用户 + 占用邀请码”原子事务),直接复用其 client,由调用方统一提交/回滚, + // 否则用户写入会落入独立事务并自行提交,导致外层事务无法回滚(孤儿用户)。 var txClient *dbent.Client txCtx := ctx - if err == nil { - defer func() { _ = tx.Rollback() }() - txClient = tx.Client() - txCtx = dbent.NewTxContext(ctx, tx) + var ownedTx *dbent.Tx + if existingTx := dbent.TxFromContext(ctx); existingTx != nil { + txClient = existingTx.Client() } else { - // 已处于外部事务中(ErrTxStarted),复用当前事务 client 并由调用方负责提交/回滚。 - if existingTx := dbent.TxFromContext(ctx); existingTx != nil { - txClient = existingTx.Client() - } else { + tx, err := r.client.Tx(ctx) + switch { + case errors.Is(err, dbent.ErrTxStarted): + // r.client 本身已是事务绑定 client(client 注入式事务,如集成测试 + // 夹具 tx.Client()):直接复用,提交/回滚由 client 的持有方负责。 txClient = r.client + case err != nil: + return err + default: + ownedTx = tx + defer func() { _ = ownedTx.Rollback() }() + txClient = tx.Client() + txCtx = dbent.NewTxContext(ctx, tx) } } @@ -158,8 +165,8 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard return err } - if tx != nil { - if err := tx.Commit(); err != nil { + if ownedTx != nil { + if err := ownedTx.Commit(); err != nil { return err } } @@ -1138,12 +1145,17 @@ func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) ( } func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) { + _, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, 0) + return exists, err +} + +func emailAliasOwnerIDWithClient(ctx context.Context, client *dbent.Client, email string, currentUserID int64) (int64, bool, error) { if client == nil { - return false, nil + return 0, false, nil } probes := service.EmailAliasDedupProbes(email) if len(probes) == 0 { - return false, nil + return 0, false, nil } preds := make([]predicate.User, 0, 2*len(probes)) @@ -1157,20 +1169,82 @@ func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, ema candidates, err := client.User.Query(). Where(dbuser.Or(preds...)). Limit(emailAliasCandidateLimit). - Select(dbuser.FieldEmail). - Strings(ctx) + Select(dbuser.FieldID, dbuser.FieldEmail). + All(ctx) if err != nil { - return false, err + return 0, false, err } // 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。 + // 返回“其他用户”优先于当前用户,避免历史重复数据让调用方误判为仅当前用户占用。 identity := service.NormalizeEmailForAliasDedup(email) + var selfID int64 + selfExists := false for _, candidate := range candidates { - if service.NormalizeEmailForAliasDedup(candidate) == identity { - return true, nil + if service.NormalizeEmailForAliasDedup(candidate.Email) != identity { + continue + } + if candidate.ID != 0 && candidate.ID != currentUserID { + return candidate.ID, true, nil + } + if candidate.ID == currentUserID { + selfID = candidate.ID + selfExists = true } } - return false, nil + return selfID, selfExists, nil +} + +// UpdateEmailWithAliasGuard 在调用方事务内更新主邮箱与密码哈希。 +// +// 邮箱换绑不能只依赖服务层前置查重:两个并发请求可能同时看到同一收件箱未被占用。 +// 这里先按“字面邮箱 + 收件箱身份”加锁,复查是否已被其他用户占用,再执行写入; +// PostgreSQL 使用事务级 advisory lock 跨实例互斥,测试内存库则由进程内锁兜底。 +func (r *userRepository) UpdateEmailWithAliasGuard( + ctx context.Context, + userID int64, + email string, + passwordHash string, +) error { + if userID <= 0 { + return service.ErrUserNotFound + } + if strings.TrimSpace(email) == "" || passwordHash == "" { + return fmt.Errorf("email identity update requires email and password hash") + } + tx := dbent.TxFromContext(ctx) + if tx == nil { + return fmt.Errorf("email identity update requires a transaction") + } + client := tx.Client() + + releaseEmailLock, err := lockRepositoryScopedKeys( + ctx, + client, + txAwareSQLExecutor(ctx, r.sql, r.client), + normalizedEmailUniquenessLockKey(email), + emailAliasUniquenessLockKey(email), + ) + if err != nil { + return err + } + defer releaseEmailLock() + + ownerID, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, userID) + if err != nil { + return err + } + if exists && ownerID != userID { + return service.ErrEmailExists + } + + if _, err := client.User.UpdateOneID(userID). + SetEmail(email). + SetPasswordHash(passwordHash). + Save(ctx); err != nil { + return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists) + } + return nil } // dotStrippedEmailExpr 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与 diff --git a/backend/internal/repository/user_repo_invitation_claim_test.go b/backend/internal/repository/user_repo_invitation_claim_test.go new file mode 100644 index 0000000000..ba24c365b9 --- /dev/null +++ b/backend/internal/repository/user_repo_invitation_claim_test.go @@ -0,0 +1,113 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/redeemcode" + "github.com/Wei-Shaw/sub2api/ent/user" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// TestCreateWithEmailAliasGuardJoinsOuterTransaction 验证用户创建会加入调用方开启的 +// 外部 ent 事务(注册流程“建用户 + 占用邀请码”原子性的基础): +// - 外层事务回滚后,用户与邀请码占用必须一并撤销(不得残留孤儿账号); +// - 外层事务提交后,用户与邀请码占用同时生效。 +// +// 回归背景:此前 create() 通过 r.client.Tx(ctx) 自开事务且自行 Commit —— ent 的 +// Client.Tx 不感知上下文事务(只检查 driver 类型),ErrTxStarted 分支实际是死代码, +// 导致外层事务包不住用户写入;并发注册同一邀请码时,败者用户的写入已自行提交, +// 即使注册被拒绝也会留下可登录的孤儿账号(1 个邀请码仍可生成任意数量账号)。 +func TestCreateWithEmailAliasGuardJoinsOuterTransaction(t *testing.T) { + client := testEntClient(t) + userRepo := NewUserRepository(client, integrationDB) + redeemRepo := NewRedeemCodeRepository(client) + + ctx := context.Background() + + // 清理:本测试会真实提交少量数据,确保不影响同包其它集成测试。 + var committedUserEmails []string + var seededCodeIDs []int64 + t.Cleanup(func() { + if len(committedUserEmails) > 0 { + _, _ = client.User.Delete().Where(user.EmailIn(committedUserEmails...)).Exec(ctx) + } + if len(seededCodeIDs) > 0 { + _, _ = client.RedeemCode.Delete().Where(redeemcode.IDIn(seededCodeIDs...)).Exec(ctx) + } + }) + + seedCode := func(code string) int64 { + _, err := client.RedeemCode.Create(). + SetCode(code). + SetType(service.RedeemTypeInvitation). + SetStatus(service.StatusUnused). + SetValue(0). + Save(ctx) + require.NoError(t, err, "seed redeem code") + c, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(code)).Only(ctx) + require.NoError(t, err) + seededCodeIDs = append(seededCodeIDs, c.ID) + return c.ID + } + + t.Run("rollback removes user and releases claim", func(t *testing.T) { + codeID := seedCode("ITX-RACE-ROLLBACK-001") + tx, err := client.Tx(ctx) + require.NoError(t, err) + txCtx := dbent.NewTxContext(ctx, tx) + + u := &service.User{ + Email: "itx-rollback@example.com", + PasswordHash: "test-password-hash", + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 0, + Concurrency: 1, + } + require.NoError(t, userRepo.CreateWithEmailAliasGuard(txCtx, u)) + require.Greater(t, u.ID, int64(0), "create 应回填用户 ID") + require.NoError(t, redeemRepo.Use(txCtx, codeID, u.ID)) + require.NoError(t, tx.Rollback()) + + exists, err := userRepo.ExistsByEmail(ctx, "itx-rollback@example.com") + require.NoError(t, err) + require.False(t, exists, "回滚后不得残留孤儿用户") + + after, err := client.RedeemCode.Get(ctx, codeID) + require.NoError(t, err) + require.Equal(t, service.StatusUnused, after.Status, "回滚后邀请码应保持 unused") + }) + + t.Run("commit persists user and claim together", func(t *testing.T) { + codeID := seedCode("ITX-RACE-COMMIT-001") + tx, err := client.Tx(ctx) + require.NoError(t, err) + txCtx := dbent.NewTxContext(ctx, tx) + + u := &service.User{ + Email: "itx-commit@example.com", + PasswordHash: "test-password-hash", + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 0, + Concurrency: 1, + } + require.NoError(t, userRepo.CreateWithEmailAliasGuard(txCtx, u)) + require.NoError(t, redeemRepo.Use(txCtx, codeID, u.ID)) + require.NoError(t, tx.Commit()) + committedUserEmails = append(committedUserEmails, u.Email) + + exists, err := userRepo.ExistsByEmail(ctx, "itx-commit@example.com") + require.NoError(t, err) + require.True(t, exists, "提交后用户应存在") + + after, err := client.RedeemCode.Get(ctx, codeID) + require.NoError(t, err) + require.Equal(t, service.StatusUsed, after.Status, "提交后邀请码应为 used") + }) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 34c1a1b9d2..e6904f833c 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet( NewUserGroupRateRepository, NewErrorPassthroughRepository, NewTLSFingerprintProfileRepository, + NewPluginRepository, NewChannelRepository, NewChannelMonitorRepository, NewChannelMonitorV2Repository, diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 2ce102296a..85b43e2c0d 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -136,14 +136,28 @@ func (m *ConfigManager) Reload(ctx context.Context) error { previous := m.snapshot.Load() m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now}) m.configUntrusted.Store(false) - m.clearLoadError() + recovered := m.clearLoadError() m.logInvalidTokenEndpoints(previous, active) - LogInfo(EventConfigLoaded, map[string]any{ - "config_version": storage.ConfigVersion, "status": "loaded", - }) + // refreshLoop calls Reload every 5s, so logging every successful load turns + // config_loaded into a heartbeat that buries real config changes. + if recovered || shouldLogConfigLoaded(previous, storage, active) { + LogInfo(EventConfigLoaded, map[string]any{ + "config_version": storage.ConfigVersion, "status": "loaded", + }) + } return nil } +// shouldLogConfigLoaded reports whether a successful reload carries news: the +// first snapshot, a new config version (every admin save bumps it under the +// advisory lock in UpdateConfig) or a flip of the global risk control gate, +// which lives in its own setting and so leaves the version untouched. +func shouldLogConfigLoaded(previous *activeConfigSnapshot, storage storageConfig, active ActiveConfig) bool { + return previous == nil || + previous.storage.ConfigVersion != storage.ConfigVersion || + previous.active.RiskControlEnabled != active.RiskControlEnabled +} + // logInvalidTokenEndpoints warns once per change (not on every 5s refresh) // when stored endpoint tokens cannot be decrypted with the current key. func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) { @@ -490,11 +504,15 @@ func (m *ConfigManager) recordLoadError(_ error) { m.stateMu.Unlock() } -func (m *ConfigManager) clearLoadError() { +// clearLoadError drops the recorded load failure and reports whether one was +// pending, so callers can tell a recovery apart from an unchanged reload. +func (m *ConfigManager) clearLoadError() bool { m.stateMu.Lock() + recovered := m.lastLoadError != "" m.lastLoadError = "" m.lastErrorAt = nil m.stateMu.Unlock() + return recovered } func cloneStorageConfig(cfg storageConfig) storageConfig { diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 48175947c2..eb7bfcfa02 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -1,9 +1,11 @@ package securityaudit import ( + "bytes" "context" "encoding/json" "errors" + "log/slog" "strings" "testing" @@ -454,3 +456,51 @@ func TestUpdateConfigStrictBoundsAndKnownValues(t *testing.T) { }) } } + +// Regression coverage for issue #5732: refreshLoop reloads every 5s, so +// config_loaded must stay a change signal instead of ~17k identical lines a +// day, while still reporting the first load, real config changes and a +// recovery from a failed reload. +func TestConfigLoadedIsLoggedOnlyWhenSomethingChanged(t *testing.T) { + storage := DefaultStorageConfig() + storage.ConfigVersion = 4 + raw, err := json.Marshal(storage) + require.NoError(t, err) + repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: string(raw), + SettingKeyRiskControl: "false", + }}} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig()) + + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil))) + t.Cleanup(func() { slog.SetDefault(previous) }) + loadedCount := func() int { return strings.Count(output.String(), EventConfigLoaded) } + + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 1, loadedCount(), "the first successful load must be logged") + + require.NoError(t, manager.Reload(context.Background())) + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 1, loadedCount(), "TTL refreshes of an unchanged config must stay silent") + + repository.values[SettingKeyRiskControl] = "true" + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 2, loadedCount(), "flipping the global risk control gate must be logged") + + storage.ConfigVersion = 5 + raw, err = json.Marshal(storage) + require.NoError(t, err) + repository.values[SettingKeyPromptAuditConfig] = string(raw) + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 3, loadedCount(), "a new config version must be logged") + + repository.loadErr = errors.New("settings unavailable") + require.Error(t, manager.Reload(context.Background())) + require.Equal(t, 3, loadedCount(), "a failed reload must not claim a load") + + repository.loadErr = nil + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 4, loadedCount(), "recovering from a failed reload must be visible") +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 79dfb919a6..d1641490c4 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -861,7 +861,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -886,7 +886,7 @@ func TestAPIContracts(t *testing.T) { "invitation_code_enabled": false, "home_content": "", "hide_ccs_import_button": false, - "grok_default_text_model": "grok-4.5", + "grok_default_text_model": "grok-4.6", "grok_default_base_url_mode": "cli", "grok_cross_client_model_map_enabled": true, "purchase_subscription_enabled": false, @@ -988,11 +988,13 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_enabled": true, "channel_monitor_mode": "v1", "channel_monitor_hide_throughput": true, + "channel_monitor_show_quota": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, "model_plaza_require_auth": false, "model_plaza_description": "", + "plugin_management_enabled": false, "risk_control_enabled": false, "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, @@ -1168,14 +1170,14 @@ func TestAPIContracts(t *testing.T) { "doc_url": "", "home_content": "", "hide_ccs_import_button": false, - "grok_default_text_model": "grok-4.5", + "grok_default_text_model": "grok-4.6", "grok_default_base_url_mode": "cli", "grok_cross_client_model_map_enabled": true, "purchase_subscription_enabled": false, "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1298,11 +1300,13 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_enabled": true, "channel_monitor_mode": "v1", "channel_monitor_hide_throughput": true, + "channel_monitor_show_quota": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, "model_plaza_require_auth": false, "model_plaza_description": "", + "plugin_management_enabled": false, "risk_control_enabled": false, "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index bb5d99abe2..df2a3940ce 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -53,6 +53,9 @@ var requiredCSPDirectiveValues = []struct { directive string value string }{ + // 插件配置 UI 使用同源 iframe;目标响应仍必须显式放开 X-Frame-Options, + // 因此这里只允许 'self' 不会使其他默认 DENY 的管理/API 页面可被嵌入。 + {"frame-src", "'self'"}, {"script-src", CloudflareInsightsDomain}, {"script-src", TencentCaptchaDomain}, {"frame-src", TencentCaptchaDomain}, @@ -196,35 +199,41 @@ func directiveHasValue(policy, directive, value string) bool { // addToDirective adds a value to a specific CSP directive. // If the directive doesn't exist, it will be added after default-src. func addToDirective(policy, directive, value string) string { - // Find the directive in the policy - directivePrefix := directive + " " - idx := strings.Index(policy, directivePrefix) - - if idx == -1 { - // Directive not found, add it after default-src or at the beginning - defaultSrcIdx := strings.Index(policy, "default-src ") - if defaultSrcIdx != -1 { - // Find the end of default-src directive (next semicolon) - endIdx := strings.Index(policy[defaultSrcIdx:], ";") - if endIdx != -1 { - insertPos := defaultSrcIdx + endIdx + 1 - // Insert new directive after default-src - return policy[:insertPos] + " " + directive + " 'self' " + value + ";" + policy[insertPos:] - } - } - // Fallback: prepend the directive - return directive + " 'self' " + value + "; " + policy + if end, ok := cspDirectiveEnd(policy, directive); ok { + return policy[:end] + " " + value + policy[end:] } - - // Find the end of this directive (next semicolon or end of string) - endIdx := strings.Index(policy[idx:], ";") - - if endIdx == -1 { - // No semicolon found, directive goes to end of string - return policy + " " + value + trimmed := strings.TrimSpace(policy) + if trimmed == "" { + return newCSPDirective(directive, value) } + if !strings.HasSuffix(trimmed, ";") { + trimmed += ";" + } + return trimmed + " " + newCSPDirective(directive, value) +} - // Insert value before the semicolon - insertPos := idx + endIdx - return policy[:insertPos] + " " + value + policy[insertPos:] +func cspDirectiveEnd(policy, directive string) (int, bool) { + start := 0 + for start <= len(policy) { + end := len(policy) + if relativeEnd := strings.IndexByte(policy[start:], ';'); relativeEnd >= 0 { + end = start + relativeEnd + } + fields := strings.Fields(policy[start:end]) + if len(fields) > 0 && fields[0] == directive { + return end, true + } + if end == len(policy) { + break + } + start = end + 1 + } + return 0, false +} + +func newCSPDirective(directive, value string) string { + if value == "'self'" { + return directive + " 'self';" + } + return directive + " 'self' " + value + ";" } diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index cb355e0c4d..cdd169c154 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -132,6 +132,25 @@ func TestSecurityHeaders(t *testing.T) { assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource)) }) + t.Run("old_custom_policy_dynamically_allows_same_origin_frames", func(t *testing.T) { + cfg := config.CSPConfig{ + Enabled: true, + Policy: "default-src 'self'; frame-src https://checkout.example.com", + } + middleware := SecurityHeaders(cfg, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/admin/plugins", nil) + + middleware(c) + + csp := w.Header().Get("Content-Security-Policy") + assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "'self'")) + assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "https://checkout.example.com")) + assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options")) + }) + t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) { cfg := config.CSPConfig{ Enabled: true, @@ -298,6 +317,21 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Contains(t, enhanced, CloudflareInsightsDomain) }) + t.Run("allows_only_same_origin_plugin_frames", func(t *testing.T) { + policy := "default-src 'self'; frame-src https://checkout.example.com" + enhanced := enhanceCSPPolicy(policy) + + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'")) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "https://checkout.example.com")) + assert.NotContains(t, enhanced, "frame-src *") + }) + + t.Run("adds_same_origin_frame_source_once_when_directive_is_missing", func(t *testing.T) { + enhanced := enhanceCSPPolicy("default-src 'self'; script-src 'self'") + + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'")) + }) + t.Run("does_not_duplicate_nonce_placeholder", func(t *testing.T) { policy := "default-src 'self'; script-src 'self' __CSP_NONCE__" enhanced := enhanceCSPPolicy(policy) @@ -449,6 +483,15 @@ func TestAddToDirective(t *testing.T) { assert.Contains(t, result, "script-src") assert.Contains(t, result, "https://example.com") }) + + t.Run("does_not_match_a_directive_name_suffix", func(t *testing.T) { + policy := "default-src 'none'; child-frame-src https://legacy.example.com" + result := addToDirective(policy, "frame-src", "'self'") + + assert.Equal(t, 1, countDirectiveValue(result, "frame-src", "'self'")) + assert.Equal(t, 0, countDirectiveValue(result, "child-frame-src", "'self'")) + assert.Equal(t, 1, countDirectiveValue(result, "child-frame-src", "https://legacy.example.com")) + }) } // Benchmark tests diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 149d4f95bc..d618fb45e7 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -20,6 +20,9 @@ func RegisterAdminRoutes( settingService *service.SettingService, panelRateLimiter *middleware.PanelRateLimiter, ) { + // 插件 UI 使用短时能力 URL,仅提供经过安装校验的静态资源。 + v1.GET("/plugin-ui/:token/*path", h.Admin.Plugin.ServeUIAsset) + admin := v1.Group("/admin") admin.Use(gin.HandlerFunc(adminAuth)) // 面板全局按用户限流(默认管理员豁免,可在系统设置中关闭豁免) @@ -58,6 +61,9 @@ func RegisterAdminRoutes( // Grok OAuth registerGrokOAuthRoutes(admin, h) + // 国产供应商(kimi/zhipu/deepseek)额度与余额 + registerCNProviderRoutes(admin, h) + // 代理管理 registerProxyRoutes(admin, h, stepUpAuth) @@ -97,6 +103,9 @@ func RegisterAdminRoutes( // TLS 指纹模板管理 registerTLSFingerprintProfileRoutes(admin, h) + // 本地进程插件管理 + registerPluginRoutes(admin, h, stepUpAuth) + // API Key 管理 registerAdminAPIKeyRoutes(admin, h) @@ -482,6 +491,17 @@ func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } +// registerCNProviderRoutes 注册国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。 +func registerCNProviderRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + cn := admin.Group("/cn-providers") + { + // Coding Plan 滚动窗口用量(kimi/zhipu coding 账号)。 + cn.GET("/accounts/:id/quota", h.Admin.CNProvider.QueryQuota) + // payg 账号余额(kimi/deepseek;zhipu 无余额端点)。 + cn.GET("/accounts/:id/balance", h.Admin.CNProvider.QueryBalance) + } +} + func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { proxies := admin.Group("/proxies") { @@ -722,6 +742,22 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand } } +func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { + plugins := admin.Group("/plugins") + { + plugins.GET("", h.Admin.Plugin.List) + plugins.GET("/:id", h.Admin.Plugin.Get) + plugins.POST("/upload", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Upload) + plugins.POST("/:id/enable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Enable) + plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable) + plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete) + plugins.GET("/:id/config", h.Admin.Plugin.GetConfig) + plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig) + plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test) + plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession) + } +} + func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) { channels := admin.Group("/channels") { diff --git a/backend/internal/server/routes/composite_platform_test.go b/backend/internal/server/routes/composite_platform_test.go index 682d129ebc..3e64d2fbb8 100644 --- a/backend/internal/server/routes/composite_platform_test.go +++ b/backend/internal/server/routes/composite_platform_test.go @@ -133,6 +133,78 @@ func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteAndRewritesBody(t *te require.Equal(t, http.StatusNoContent, w.Code) } +func TestCompositeTargetPlatformMiddlewareRewritesNestedLiveModel(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []service.CompositeModelRoute{ + { + ID: 1, + GroupID: 1, + PublicModel: "live-alias", + MatchType: service.CompositeRouteMatchExact, + TargetPlatform: service.PlatformOpenAI, + UpstreamModel: "gpt-live", + Endpoint: service.CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { + groupID := int64(1) + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{ + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + c.Next() + }))) + router.Use(compositeTargetPlatformMiddleware(resolver)) + router.POST("/backend-api/codex/realtime/calls", func(c *gin.Context) { + platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()) + require.True(t, ok) + require.Equal(t, service.PlatformOpenAI, platform) + + body, err := io.ReadAll(c.Request.Body) + require.NoError(t, err) + require.JSONEq(t, `{"session":{"model":"gpt-live"},"sdp":"v=0"}`, string(body)) + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest( + http.MethodPost, + "/backend-api/codex/realtime/calls", + strings.NewReader(`{"session":{"model":"live-alias"},"sdp":"v=0"}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("sdp", "v=0")) + require.NoError(t, writer.WriteField("session", `{"model":"live-alias"}`)) + require.NoError(t, writer.Close()) + + require.Equal(t, "live-alias", compositeRequestModelFromBody(writer.FormDataContentType(), body.Bytes())) +} + +func TestCompositeCodexControlPathsUseResponsesRoutes(t *testing.T) { + for _, path := range []string{ + "/v1/alpha/search", + "/backend-api/codex/alpha/search", + "/v1/live", + "/backend-api/codex/realtime/calls", + } { + require.Equal(t, service.CompositeRouteEndpointResponses, compositeRouteEndpointForPath(path), "path=%s", path) + } +} + func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteForMultipartImages(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 6d99d2c7ea..fe753b2389 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -47,18 +47,17 @@ func RegisterGatewayRoutes( isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool { switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformGrok: + case service.PlatformOpenAI, service.PlatformGrok, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: + // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。 return true default: return false } } - isOpenAIGatewayPlatform := func(c *gin.Context) bool { - return getGroupPlatform(c) == service.PlatformOpenAI - } countTokensHandler := func(c *gin.Context) { switch getGroupPlatform(c) { - case service.PlatformOpenAI: + case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: h.OpenAIGateway.CountTokens(c) case service.PlatformGrok: h.OpenAIGateway.GrokCountTokens(c) @@ -66,9 +65,12 @@ func RegisterGatewayRoutes( h.Gateway.CountTokens(c) } } + codexModelsHandler := func(c *gin.Context) { + dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels) + } modelsHandler := func(c *gin.Context) { - if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" { - h.OpenAIGateway.CodexModels(c) + if c.Query("client_version") != "" { + codexModelsHandler(c) return } h.Gateway.Models(c) @@ -93,7 +95,10 @@ func RegisterGatewayRoutes( } } videoGenerationHandler := func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformGrok { + // Video status/content lookups below already allow Composite groups; keep + // task creation aligned so composite keys that route to Grok accounts can + // submit video generation jobs. + if platform := getGroupPlatform(c); platform == service.PlatformGrok || platform == service.PlatformComposite { h.OpenAIGateway.GrokVideoGeneration(c) return } @@ -168,6 +173,10 @@ func RegisterGatewayRoutes( }) return } + if service.IsOpenAIResponsesInputTokensRequestPath(c) && isOpenAIResponsesCompatibleGatewayPlatform(c) { + h.OpenAIGateway.ResponsesInputTokens(c) + return + } next(c) } } @@ -368,7 +377,7 @@ func RegisterGatewayRoutes( codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) - codexDirect.GET("/models", h.OpenAIGateway.CodexModels) + codexDirect.GET("/models", codexModelsHandler) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { @@ -495,6 +504,14 @@ func RegisterGatewayRoutes( } +func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) { + if getGroupPlatform(c) == service.PlatformOpenAI { + openAIHandler(c) + return + } + generatedHandler(c) +} + // getGroupPlatform extracts the group platform from the API Key stored in context. func getGroupPlatform(c *gin.Context) string { apiKey, ok := middleware.GetAPIKeyFromContext(c) @@ -549,8 +566,10 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) if decision.Matched { c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision)) if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) { - if rewritten, rewriteErr := sjson.SetBytes(body, "model", upstreamModel); rewriteErr == nil { - body = rewritten + if _, modelPath := compositeJSONRequestModel(body); modelPath != "" { + if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil { + body = rewritten + } } } } @@ -561,12 +580,25 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) } func compositeRequestModelFromBody(contentType string, body []byte) string { - if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" { + if model, _ := compositeJSONRequestModel(body); model != "" { return model } return compositeMultipartModelFromBody(contentType, body) } +func compositeJSONRequestModel(body []byte) (string, string) { + for _, path := range []string{"model", "session.model"} { + model := gjson.GetBytes(body, path) + if model.Type != gjson.String { + continue + } + if value := strings.TrimSpace(model.String()); value != "" { + return value, path + } + } + return "", "" +} + func compositeMultipartModelFromBody(contentType string, body []byte) string { mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType)) if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { @@ -585,14 +617,22 @@ func compositeMultipartModelFromBody(contentType string, body []byte) string { if err != nil { return "" } - if part.FormName() != "model" || part.FileName() != "" { + fieldName := part.FormName() + if part.FileName() != "" || (fieldName != "model" && fieldName != "session") { continue } data, err := io.ReadAll(part) if err != nil { return "" } - return strings.TrimSpace(string(data)) + switch fieldName { + case "model": + return strings.TrimSpace(string(data)) + case "session": + if model, _ := compositeJSONRequestModel(data); model != "" { + return model + } + } } } @@ -668,7 +708,10 @@ func compositeRouteEndpointForPath(path string) string { return service.CompositeRouteEndpointCountTokens case strings.Contains(path, "/messages"): return service.CompositeRouteEndpointMessages - case strings.Contains(path, "/responses"): + case strings.Contains(path, "/responses"), + strings.Contains(path, "/alpha/search"), + strings.Contains(path, "/realtime/calls"), + strings.HasSuffix(strings.TrimRight(path, "/"), "/live"): return service.CompositeRouteEndpointResponses case strings.Contains(path, "/chat/completions"): return service.CompositeRouteEndpointChatCompletions diff --git a/backend/internal/server/routes/gateway_codex_models_test.go b/backend/internal/server/routes/gateway_codex_models_test.go index 04a8b8fa67..0c6542a0fa 100644 --- a/backend/internal/server/routes/gateway_codex_models_test.go +++ b/backend/internal/server/routes/gateway_codex_models_test.go @@ -2,8 +2,12 @@ package routes import ( "net/http" + "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -22,3 +26,39 @@ func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) { require.NotEmpty(t, registered["/models"], "GET /models should be registered") require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler") } + +func TestDispatchCodexModelsGatewayKeepsOnlyOpenAIOnLiveManifestHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + platform string + wantOpenAI bool + }{ + {platform: service.PlatformOpenAI, wantOpenAI: true}, + {platform: service.PlatformComposite}, + {platform: service.PlatformGrok}, + {platform: service.PlatformDeepseek}, + } + + for _, tt := range tests { + t.Run(tt.platform, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{Platform: tt.platform}, + }) + called := "" + + dispatchCodexModelsGateway(c, + func(c *gin.Context) { called = "openai" }, + func(c *gin.Context) { called = "generated" }, + ) + + if tt.wantOpenAI { + require.Equal(t, "openai", called) + } else { + require.Equal(t, "generated", called) + } + }) + } +} diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index ded7bd2989..9ab241bdf4 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -94,7 +94,7 @@ func TestGatewayRoutesOpenAIAlphaSearchPathsAreRegistered(t *testing.T) { } } -func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) { +func TestGatewayRoutesAlphaSearchRejectsUnsupportedGroup(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformGrok) req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`)) req.Header.Set("Content-Type", "application/json") @@ -103,7 +103,7 @@ func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) { router.ServeHTTP(w, req) require.Equal(t, http.StatusNotFound, w.Code) - require.Contains(t, w.Body.String(), "only available for OpenAI groups") + require.Contains(t, w.Body.String(), "only available for OpenAI and Composite groups") } func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) { @@ -345,6 +345,18 @@ func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) { } } +func TestGatewayRoutesCompositeVideoGenerationAllowed(t *testing.T) { + router := newGatewayRoutesTestRouter(service.PlatformComposite) + + req := httptest.NewRequest(http.MethodPost, "/v1/videos/generations", strings.NewReader(`{"model":"grok-imagine-video-1.5","prompt":"waves"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.NotEqual(t, http.StatusNotFound, w.Code) + require.NotContains(t, w.Body.String(), "not supported") +} + func TestGatewayRoutesCompositeOpenAIOnlyEndpointsRequireOpenAITarget(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformComposite) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cb74aaf50f..3967e407bd 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -271,8 +272,30 @@ func (a *Account) IsGrokOAuth() bool { return a.IsGrok() && a.Type == AccountTypeOAuth } +// IsKimi / IsZhipu / IsDeepseek 标识国产 OpenAI 兼容供应商账号。 +func (a *Account) IsKimi() bool { + return a.Platform == PlatformKimi +} + +func (a *Account) IsZhipu() bool { + return a.Platform == PlatformZhipu +} + +func (a *Account) IsDeepseek() bool { + return a.Platform == PlatformDeepseek +} + +// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。 +func (a *Account) IsCNProvider() bool { + return a != nil && IsCNProvider(a.Platform) +} + +// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。 +// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions +// 兼容上游,也经 OpenAI 网关转发。 func (a *Account) IsOpenAICompatible() bool { - return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok) + return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || + a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek) } func (a *Account) GeminiOAuthType() string { @@ -302,6 +325,12 @@ func (a *Account) IsGeminiCodeAssist() bool { return oauthType == "code_assist" } +// IsGeminiGoogleOne reports whether this account uses the legacy consumer +// Gemini CLI / Code Assist OAuth channel. +func (a *Account) IsGeminiGoogleOne() bool { + return a.Platform == PlatformGemini && a.Type == AccountTypeOAuth && a.GeminiOAuthType() == "google_one" +} + func (a *Account) CanGetUsage() bool { return a.Type == AccountTypeOAuth } @@ -603,6 +632,9 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri return nil } if len(rawMapping) == 0 { + if a.IsGeminiGoogleOne() { + return geminicli.GoogleOneModelMapping() + } // Antigravity 平台使用默认映射 if a.Platform == domain.PlatformAntigravity { return domain.DefaultAntigravityModelMapping @@ -637,6 +669,9 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri } // Antigravity 平台使用默认映射 + if a.IsGeminiGoogleOne() { + return geminicli.GoogleOneModelMapping() + } if a.Platform == domain.PlatformAntigravity { return domain.DefaultAntigravityModelMapping } @@ -1257,6 +1292,19 @@ func (a *Account) IsOpenAIOAuth() bool { return a.IsOpenAI() && a.Type == AccountTypeOAuth } +// IsOpenAIOAuthLike reports OpenAI credentials that use the ChatGPT/Codex +// inference protocol. Setup tokens share that forwarding contract but do not +// participate in the refreshable OAuth credential lifecycle. +func (a *Account) IsOpenAIOAuthLike() bool { + return a != nil && a.IsOpenAI() && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken) +} + +// UsesOpenAICodexProtocol preserves legacy OpenAI gateway OAuth routing for +// accounts whose platform is implicit, while adding OpenAI SetupToken. +func (a *Account) UsesOpenAICodexProtocol() bool { + return a != nil && (a.Type == AccountTypeOAuth || a.IsOpenAIOAuthLike()) +} + func (a *Account) IsOpenAIChatGPTSubscription() bool { if !a.IsOpenAIOAuth() { return false @@ -1281,17 +1329,233 @@ func (a *Account) IsOpenAIApiKey() bool { return a.IsOpenAI() && a.Type == AccountTypeAPIKey } +// GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。 +// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL, +// 此处对 grok 返回 "" 以保持原有行为。 func (a *Account) GetOpenAIBaseURL() string { - if !a.IsOpenAI() { + if !a.IsOpenAI() && !a.IsCNProvider() { return "" } - if a.Type == AccountTypeAPIKey { - baseURL := a.GetCredential("base_url") - if baseURL != "" { + if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() { + if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { + if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" { + return strings.TrimSpace(baseURL) + } + } + } + if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream { + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { return baseURL } } - return "https://api.openai.com" + // 平台默认 base_url:CN 供应商按 account_mode 选择 payg / coding 默认值。 + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingBaseURL + } + return DefaultKimiPayGBaseURL + case PlatformZhipu: + if a.GetAccountMode() == AccountModeCoding { + return DefaultZhipuCodingBaseURL + } + return DefaultZhipuPayGBaseURL + case PlatformDeepseek: + return DefaultDeepseekBaseURL + default: + return "https://api.openai.com" + } +} + +// GetAccountMode 返回国产供应商账号的接入模式(payg / coding);非国产供应商或未设置时 +// 返回空串。存储于 credentials["account_mode"]。 +func (a *Account) GetAccountMode() string { + if a == nil { + return "" + } + mode := strings.TrimSpace(a.GetCredential("account_mode")) + if mode == AccountModePayG || mode == AccountModeCoding { + return mode + } + return "" +} + +// IsCodingPlan 报告账号是否为 Coding Plan 模式(用于滚动用量窗口冷却)。 +func (a *Account) IsCodingPlan() bool { + return a.GetAccountMode() == AccountModeCoding +} + +// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于 +// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions +// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses +// 端点,适配 Codex);kimi/zhipu 无此端点。 +func (a *Account) GetAPIProtocol() string { + if a == nil || !a.IsCNProvider() { + return APIProtocolChatCompletions + } + switch strings.TrimSpace(a.GetCredential("api_protocol")) { + case APIProtocolAdaptive: + return APIProtocolAdaptive + case APIProtocolAnthropic: + return APIProtocolAnthropic + case APIProtocolResponses: + if a.Platform == PlatformDeepseek { + return APIProtocolResponses + } + case APIProtocolChatCompletions: + return APIProtocolChatCompletions + } + return APIProtocolChatCompletions +} + +// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。 +func (a *Account) IsAdaptiveAPIProtocol() bool { + return a.GetAPIProtocol() == APIProtocolAdaptive +} + +// GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。 +// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和 +// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。 +func (a *Account) GetCNProtocolBaseURL(protocol string) string { + if a == nil || !a.IsCNProvider() { + return "" + } + if a.IsAdaptiveAPIProtocol() { + if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { + if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" { + return strings.TrimSpace(baseURL) + } + } + if protocol == APIProtocolChatCompletions { + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { + return baseURL + } + } + } + return a.defaultCNProtocolBaseURL(protocol) +} + +func (a *Account) defaultCNProtocolBaseURL(protocol string) string { + switch protocol { + case APIProtocolAnthropic: + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingAnthropicBaseURL + } + return DefaultKimiPayGAnthropicBaseURL + case PlatformZhipu: + return DefaultZhipuAnthropicBaseURL + case PlatformDeepseek: + return DefaultDeepseekAnthropicBaseURL + } + case APIProtocolChatCompletions, APIProtocolResponses: + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingBaseURL + } + return DefaultKimiPayGBaseURL + case PlatformZhipu: + if a.GetAccountMode() == AccountModeCoding { + return DefaultZhipuCodingBaseURL + } + return DefaultZhipuPayGBaseURL + case PlatformDeepseek: + return DefaultDeepseekBaseURL + } + } + return "" +} + +// IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游 +// (/v1/messages 直通,适配 Claude Code 等客户端)。 +func (a *Account) IsAnthropicProtocol() bool { + return a.GetAPIProtocol() == APIProtocolAnthropic +} + +// GetAnthropicProtocolBaseURL 返回 Anthropic 协议账号的上游 base_url +// (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按 +// 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。 +func (a *Account) GetAnthropicProtocolBaseURL() string { + if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) { + return "" + } + if a.IsAdaptiveAPIProtocol() { + return a.GetCNProtocolBaseURL(APIProtocolAnthropic) + } + if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream { + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { + return baseURL + } + } + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingAnthropicBaseURL + } + return DefaultKimiPayGAnthropicBaseURL + case PlatformZhipu: + return DefaultZhipuAnthropicBaseURL + case PlatformDeepseek: + return DefaultDeepseekAnthropicBaseURL + default: + return "" + } +} + +// GetOpenAIFormatBaseURL 返回供 OpenAI 格式端点(/v1/models、/v1/chat/completions +// 等)使用的 base。chat_completions / responses 协议下与 GetOpenAIBaseURL +// 一致(凭证 base_url 或平台默认);anthropic 协议下凭证 base_url 指向 Anthropic +// 端点,不能拿来拼 OpenAI 路径,此时返回该供应商 × 模式的 Chat Completions +// 默认 base(模型同步等协议族共用路径仍可用)。 +func (a *Account) GetOpenAIFormatBaseURL() string { + if a == nil || !a.IsAnthropicProtocol() { + return a.GetOpenAIBaseURL() + } + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingBaseURL + } + return DefaultKimiPayGBaseURL + case PlatformZhipu: + if a.GetAccountMode() == AccountModeCoding { + return DefaultZhipuCodingBaseURL + } + return DefaultZhipuPayGBaseURL + case PlatformDeepseek: + return DefaultDeepseekBaseURL + default: + return a.GetOpenAIBaseURL() + } +} + +// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。 +// 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。 +func (a *Account) GetCNAPIKey() string { + if a == nil || !a.IsCNProvider() { + return "" + } + return a.GetCredential("api_key") +} + +// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu), +// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。 +// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。 +func (a *Account) GetCodingPlanProvider() string { + if a == nil || a.GetAccountMode() != AccountModeCoding { + return "" + } + baseURL := strings.ToLower(a.GetOpenAIBaseURL()) + switch { + case strings.Contains(baseURL, "api.kimi.com/coding"): + return PlatformKimi + case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"): + return PlatformZhipu + default: + return "" + } } func (a *Account) GetOpenAIAccessToken() string { @@ -1404,6 +1668,23 @@ func (a *Account) GetOpenAIApiKey() string { return a.GetCredential("api_key") } +// GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。 +// 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号, +// 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上 +// 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。 +func (a *Account) GetOpenAIProtocolAPIKey() string { + if a == nil { + return "" + } + if a.IsCNProvider() { + if a.Type != AccountTypeAPIKey { + return "" + } + return a.GetCredential("api_key") + } + return a.GetOpenAIApiKey() +} + func (a *Account) GetOpenAIUserAgent() string { if !a.IsOpenAI() { return "" @@ -1412,14 +1693,14 @@ func (a *Account) GetOpenAIUserAgent() string { } func (a *Account) GetChatGPTAccountID() string { - if !a.IsOpenAIOAuth() { + if !a.IsOpenAIOAuthLike() { return "" } return a.GetCredential("chatgpt_account_id") } func (a *Account) IsChatGPTAccountFedRAMP() bool { - if !a.IsOpenAIOAuth() || a.Credentials == nil { + if !a.IsOpenAIOAuthLike() || a.Credentials == nil { return false } v, ok := a.Credentials["chatgpt_account_is_fedramp"] @@ -1593,18 +1874,30 @@ func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) { result[value] = true } + // 空容器({} / [])与未配置一致:不限制任何能力。 + // 避免 OAuth 账号因 API 直写/导入/历史数据遗留的空对象而被调度器静默排除(#5530)。 + // 注意:非空但全 false / 类型异常的数据仍视为「已配置且不含能力」,保持原行为。 switch capabilities := raw.(type) { case []any: + if len(capabilities) == 0 { + return nil, false + } for _, item := range capabilities { if value, ok := item.(string); ok { add(value) } } case []string: + if len(capabilities) == 0 { + return nil, false + } for _, value := range capabilities { add(value) } case map[string]any: + if len(capabilities) == 0 { + return nil, false + } for key, value := range capabilities { enabled, ok := value.(bool) if ok && enabled { @@ -1612,6 +1905,9 @@ func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) { } } case map[string]bool: + if len(capabilities) == 0 { + return nil, false + } for key, enabled := range capabilities { if enabled { add(key) @@ -1631,7 +1927,7 @@ func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapabilit } switch capability { case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative: - return a.Type == AccountTypeOAuth || a.Type == AccountTypeAPIKey + return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken || a.Type == AccountTypeAPIKey default: return true } @@ -1734,7 +2030,7 @@ func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool { if a == nil || !a.IsOpenAI() || a.Extra == nil { return false } - if a.IsOpenAIOAuth() { + if a.IsOpenAIOAuthLike() { if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok { return enabled } @@ -1837,7 +2133,7 @@ func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) stri return OpenAIWSIngressModeOff, true } - if a.IsOpenAIOAuth() { + if a.IsOpenAIOAuthLike() { if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok { return mode } diff --git a/backend/internal/service/account_codex_fingerprint_seed_lifecycle_test.go b/backend/internal/service/account_codex_fingerprint_seed_lifecycle_test.go new file mode 100644 index 0000000000..029b4fb009 --- /dev/null +++ b/backend/internal/service/account_codex_fingerprint_seed_lifecycle_test.go @@ -0,0 +1,245 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +const userSuppliedCodexFingerprintSeed = "22222222-2222-4222-8222-222222222222" + +func requireValidCodexFingerprintSeed(t *testing.T, extra map[string]any) string { + t.Helper() + seed, ok := codexFingerprintSeed(extra) + require.True(t, ok, "expected valid canonical Codex fingerprint seed") + return seed +} + +func TestAdminCreateAccountStripsUserSeedAndCreatesFreshSeedWhenEnabled(t *testing.T) { + repo := &upstreamBillingProbeAccountRepo{} + svc := &adminServiceImpl{accountRepo: repo} + + created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ + Name: "codex-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + SkipDefaultGroupBind: true, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed, + }, + }) + + require.NoError(t, err) + seed := requireValidCodexFingerprintSeed(t, created.Extra) + require.NotEqual(t, userSuppliedCodexFingerprintSeed, seed) + require.Equal(t, "session", created.Extra[codexFingerprintModeExtraKey]) +} + +func TestAdminUpdateAccountPreservesExistingSeedAndStripsUserSeed(t *testing.T) { + accountID := int64(201) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Name: "before", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: testCodexFingerprintSeed, + }, + }, + }} + svc := &adminServiceImpl{accountRepo: repo} + + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{ + codexFingerprintModeExtraKey: "full", + codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed, + "custom": "value", + }, + }) + + require.NoError(t, err) + require.Equal(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, updated.Extra)) + require.Equal(t, "full", updated.Extra[codexFingerprintModeExtraKey]) + require.Equal(t, "value", updated.Extra["custom"]) +} + +func TestAdminUpdateAccountInitializesSeedWhenFullEditEnables(t *testing.T) { + accountID := int64(202) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Name: "before", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "off", + codexFingerprintSeedExtraKey: "not-a-seed", + }, + }, + }} + + updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{codexFingerprintModeExtraKey: "device"}, + }) + + require.NoError(t, err) + require.NotEqual(t, "not-a-seed", requireValidCodexFingerprintSeed(t, updated.Extra)) + require.Equal(t, "device", updated.Extra[codexFingerprintModeExtraKey]) +} + +func TestAdminUpdateAccountDisableReenablePreservesValidSeed(t *testing.T) { + accountID := int64(203) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: testCodexFingerprintSeed, + }, + }, + }} + svc := &adminServiceImpl{accountRepo: repo} + + disabled, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{codexFingerprintModeExtraKey: "off"}, + }) + require.NoError(t, err) + require.Equal(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, disabled.Extra)) + + reenabled, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{codexFingerprintModeExtraKey: "session"}, + }) + require.NoError(t, err) + require.Equal(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, reenabled.Extra)) +} + +func TestAdminUpdateAccountExtraStripsSeedAndLeavesAtomicEnsureToRepository(t *testing.T) { + accountID := int64(204) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{}, + }, + }} + + err := (&adminServiceImpl{accountRepo: repo}).UpdateAccountExtra(context.Background(), accountID, map[string]any{ + codexFingerprintModeExtraKey: "device", + codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed, + }) + + require.NoError(t, err) + require.Len(t, repo.updates[accountID], 1) + require.Equal(t, "device", repo.updates[accountID][0][codexFingerprintModeExtraKey]) + require.NotContains(t, repo.updates[accountID][0], codexFingerprintSeedExtraKey) +} + +func TestBulkUpdateAccountsDoesNotPrewriteCodexSeed(t *testing.T) { + repo := &upstreamBillingProbeAccountRepo{} + + result, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{301, 302}, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed, + }, + }) + + require.NoError(t, err) + require.Equal(t, 2, result.Success) + require.Empty(t, repo.updates, "bulk enable must not loop through UpdateExtra before BulkUpdate") + require.Len(t, repo.bulkUpdates, 1) + require.True(t, repo.bulkUpdates[0].EnsureCodexFingerprintSeed) + require.Equal(t, "session", repo.bulkUpdates[0].Extra[codexFingerprintModeExtraKey]) + require.NotContains(t, repo.bulkUpdates[0].Extra, codexFingerprintSeedExtraKey) +} + +type codexSeedDuplicateRepo struct { + *upstreamBillingProbeAccountRepo +} + +func (r *codexSeedDuplicateRepo) CreateWithAccountGroups(ctx context.Context, account *Account, _ []AccountGroup) error { + return r.Create(ctx, account) +} + +func TestDuplicateAccountDoesNotCopyCodexFingerprintSeed(t *testing.T) { + ctx := context.Background() + repo := &codexSeedDuplicateRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: make(map[int64]*Account)}} + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + source := &Account{ + Name: "source", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: testCodexFingerprintSeed, + }, + } + require.NoError(t, repo.Create(ctx, source)) + + duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") + + require.NoError(t, err) + require.NotEqual(t, source.ID, duplicate.ID) + require.NotContains(t, duplicate.Extra, codexFingerprintSeedExtraKey) + require.Equal(t, "session", duplicate.Extra[codexFingerprintModeExtraKey]) +} + +func TestDuplicateCreatePathMintsFreshSeedWhenEligible(t *testing.T) { + extra, err := duplicateAccountExtra(map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: testCodexFingerprintSeed, + }) + require.NoError(t, err) + + account, err := buildAccountForCreate(&CreateAccountInput{ + Name: "eligible-copy", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: extra, + }, extra) + + require.NoError(t, err) + require.NotEqual(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, account.Extra)) + require.Equal(t, "session", account.Extra[codexFingerprintModeExtraKey]) +} + +func TestAccountServiceCreateAndUpdateCodexSeedLifecycle(t *testing.T) { + ctx := context.Background() + repo := &upstreamBillingProbeAccountRepo{accounts: make(map[int64]*Account)} + svc := NewAccountService(repo, nil) + + created, err := svc.Create(ctx, CreateAccountRequest{ + Name: "legacy-create", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed, + }, + }) + require.NoError(t, err) + createdSeed := requireValidCodexFingerprintSeed(t, created.Extra) + require.NotEqual(t, userSuppliedCodexFingerprintSeed, createdSeed) + + updateSeed := userSuppliedCodexFingerprintSeed + updated, err := svc.Update(ctx, created.ID, UpdateAccountRequest{ + Extra: &map[string]any{ + codexFingerprintModeExtraKey: "full", + codexFingerprintSeedExtraKey: updateSeed, + }, + }) + require.NoError(t, err) + require.Equal(t, createdSeed, requireValidCodexFingerprintSeed(t, updated.Extra)) + require.Equal(t, "full", updated.Extra[codexFingerprintModeExtraKey]) +} diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 506a79084d..078d98d832 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -9,8 +9,8 @@ import ( "golang.org/x/net/http/httpguts" ) -// 请求头覆写(header override):对 Anthropic / OpenAI 平台的 api_key 账号 -// 以及 Grok 平台的 api_key / oauth 账号生效。 +// 请求头覆写(header override):对 Anthropic / OpenAI / Kimi / Zhipu / DeepSeek +// 平台的 api_key 账号,以及 Grok 平台的 api_key / oauth 账号生效。 // 管理员在账号上配置一组 header name -> value,转发到上游前用配置值覆盖同名请求头 // (匹配不区分大小写);value 为空的条目视为"未填写",不参与覆盖。 const ( @@ -70,14 +70,15 @@ func isHeaderOverrideBlockedName(lowerName string) bool { } // IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。 -// Anthropic / OpenAI 仅开放 api_key 账号;Grok 额外开放 oauth 账号—— +// Anthropic / OpenAI / Kimi / Zhipu / DeepSeek 仅开放 api_key 账号; +// Grok 额外开放 oauth 账号—— // 订阅流量改发自定义转发地址时,通常需要补充中间层要求的准入头。 func (a *Account) IsHeaderOverrideEligible() bool { if a == nil { return false } switch a.Platform { - case PlatformAnthropic, PlatformOpenAI: + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek: return a.Type == AccountTypeAPIKey case PlatformGrok: return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go index 998fa543b5..9d99257f28 100644 --- a/backend/internal/service/account_header_override_test.go +++ b/backend/internal/service/account_header_override_test.go @@ -27,8 +27,14 @@ func TestIsHeaderOverrideEligible(t *testing.T) { }{ {"anthropic apikey", PlatformAnthropic, AccountTypeAPIKey, true}, {"openai apikey", PlatformOpenAI, AccountTypeAPIKey, true}, + {"kimi apikey", PlatformKimi, AccountTypeAPIKey, true}, + {"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true}, + {"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true}, {"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false}, {"openai oauth", PlatformOpenAI, AccountTypeOAuth, false}, + {"kimi oauth", PlatformKimi, AccountTypeOAuth, false}, + {"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false}, + {"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false}, {"gemini apikey", PlatformGemini, AccountTypeAPIKey, false}, {"grok apikey", PlatformGrok, AccountTypeAPIKey, true}, {"grok oauth", PlatformGrok, AccountTypeOAuth, true}, diff --git a/backend/internal/service/account_long_context_billing_test.go b/backend/internal/service/account_long_context_billing_test.go index 709559d932..08bd3f0b3f 100644 --- a/backend/internal/service/account_long_context_billing_test.go +++ b/backend/internal/service/account_long_context_billing_test.go @@ -258,18 +258,20 @@ func TestAdminServiceBulkUpdateAccountsRejectsMalformedOpenAILongContextBillingV require.Zero(t, repo.bulkUpdateCalls) } -func TestAdminServiceBulkUpdateAccountsAllowsProviderOwnedValueForNonOpenAIAccounts(t *testing.T) { +func TestAdminServiceBulkUpdateAccountsRejectsOpenAILongContextKeyForNonOpenAIAccounts(t *testing.T) { repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformGrok}} svc := &adminServiceImpl{accountRepo: repo} result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ AccountIDs: []int64{1}, - Extra: map[string]any{openAILongContextBillingEnabledKey: []string{"provider-owned"}}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, }) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, 1, repo.bulkUpdateCalls) + require.Nil(t, result) + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.Equal(t, "OPENAI_BULK_TARGET_INVALID", appErr.Reason) + require.Zero(t, repo.bulkUpdateCalls) } func TestAdminServiceBulkUpdateAccountsRejectsMalformedValueForMixedTargetsIncludingOpenAI(t *testing.T) { diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index b8a9dae80d..7dde1fd163 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -58,6 +58,10 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string] winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) case PlatformGrok: winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now) + case PlatformKimi: + winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now) + case PlatformZhipu: + winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), threshold, now) default: return decision } @@ -303,6 +307,45 @@ func grokThresholdCandidates(account *Account) []*accountSchedulingThresholdCand } } +// cnProviderThresholdCandidates 读取国产供应商 Coding Plan 账号的 5h / weekly 滚动窗口 +// 用量快照(由 CNProviderQuotaService 写入 account.Extra,键形如 +// _5h_used_percent / _weekly_reset_at)。payg 账号无此快照, +// 候选为空 → 不触发阈值停调(余额型走余额检测)。与 openai 的快照驱动停调一致: +// 仅当用量超阈值且窗口尚未重置时才停调。 +func cnProviderThresholdCandidates(account *Account, provider string) []*accountSchedulingThresholdCandidate { + if account == nil || len(account.Extra) == 0 { + return nil + } + return []*accountSchedulingThresholdCandidate{ + cnThresholdCandidate(account.Extra, provider, "5h"), + cnThresholdCandidate(account.Extra, provider, "weekly"), + } +} + +func cnThresholdCandidate(extra map[string]any, provider, window string) *accountSchedulingThresholdCandidate { + var usedKey, resetKey string + switch window { + case "5h": + usedKey = cnExtraKey(provider, cnExtraSuffix5hUsed) + resetKey = cnExtraKey(provider, cnExtraSuffix5hReset) + case "weekly": + usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed) + resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset) + default: + return nil + } + usedPercent, ok := extra[usedKey] + if !ok { + return nil + } + return &accountSchedulingThresholdCandidate{ + window: window, + scope: provider, + usedPercent: schedulingPercentValue(usedPercent), + until: parseSchedulingResetAt(extra[resetKey]), + } +} + func pickLatestResetSchedulingCandidate(candidates []*accountSchedulingThresholdCandidate, threshold int, now time.Time) *accountSchedulingThresholdCandidate { var winner *accountSchedulingThresholdCandidate for _, candidate := range candidates { diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 65d4d3f268..96689e5000 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -166,6 +166,9 @@ type AccountBulkUpdate struct { Credentials map[string]any Extra map[string]any ProbeEnabled *bool + // EnsureCodexFingerprintSeed asks the repository to atomically preserve an + // existing valid Codex fingerprint seed or create one for eligible rows. + EnsureCodexFingerprintSeed bool } // CreateAccountRequest 创建账号请求 @@ -233,7 +236,7 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( Platform: req.Platform, Type: req.Type, Credentials: SanitizeStoredCredentials(req.Platform, req.Credentials), - Extra: req.Extra, + Extra: prepareCodexFingerprintExtraForCreate(req.Platform, req.Type, req.Extra), ProxyID: req.ProxyID, Concurrency: req.Concurrency, Priority: req.Priority, @@ -336,7 +339,9 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount delete(extra, OllamaCloudUsageSessionExtraKey) delete(extra, OllamaCloudUsageAutoRefreshExtraKey) delete(extra, OllamaCloudUsageSnapshotExtraKey) - account.Extra = extra + account.Extra = prepareCodexFingerprintExtraForUpdate(account, extra) + } else { + account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra) } if req.ProxyID != nil { @@ -509,6 +514,9 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error { case PlatformGrok: // Grok OAuth credentials are validated via token exchange/refresh and request-path probes. return nil + case PlatformKimi, PlatformZhipu, PlatformDeepseek: + // 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。 + return nil default: return fmt.Errorf("unsupported platform: %s", account.Platform) } diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index df9e8e05aa..8d5bc144fc 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -68,7 +68,7 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us return nil } normalizedTier := normalizeBillingServiceTier(serviceTier) - if normalizedTier == "priority" || normalizedTier == "flex" || + if normalizedTier == "priority" || normalizedTier == "fast" || normalizedTier == "flex" || billingService.shouldApplySessionLongContextPricing(tokens, pricing) { breakdown, err := billingService.CalculateCostWithServiceTier(model, tokens, 1, normalizedTier) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 48336a5834..1bd28896fc 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -768,6 +768,26 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { + channel := &Channel{ID: 1, Status: StatusActive} + cs := newTestChannelServiceForStats(t, channel, 10, "anthropic") + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-opus-5": { + InputPricePerToken: 5e-6, + OutputPricePerToken: 25e-6, + }, + }) + + result := resolveAccountStatsCost( + context.Background(), cs, bs, + 1, 10, "claude-opus-5", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, + 1, 0, "fast", + ) + require.NotNil(t, result) + require.InDelta(t, 60, *result, 1e-12) +} + func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) { channel := &Channel{ ID: 1, diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 9e1d29c2f9..4e304eca10 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -43,8 +43,9 @@ import ( var sseDataPrefix = regexp.MustCompile(`^data:\s*`) const ( - testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" - chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" + testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" + chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" + defaultAntigravityTestModel = "claude-sonnet-4-6" ) // TestEvent represents a SSE event for account testing @@ -101,7 +102,7 @@ const ( AccountTestModeGrokRealtime = "realtime" defaultGrokRealtimeTestModel = "grok-voice-latest" - grokRealtimeProbeTimeout = 12 * time.Second + grokRealtimeProbeTimeout = DefaultGrokRealtimeDialTimeout ) // isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2). @@ -146,6 +147,10 @@ type AccountTestService struct { cfg *config.Config settingService *SettingService tlsFPProfileService *TLSFingerprintProfileService + modelMetadataRegistryMu sync.Mutex + modelMetadataRegistry map[string]modelsDevProvider + modelMetadataRegistryAt time.Time + pluginManager *PluginManager agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator // grokWSDialer is optional; realtime account tests use the default OpenAI-style @@ -159,6 +164,12 @@ func (s *AccountTestService) SetSettingService(settingService *SettingService) { } } +func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) { + if s != nil { + s.pluginManager = pluginManager + } +} + // NewAccountTestService creates a new AccountTestService func NewAccountTestService( accountRepo AccountRepository, @@ -285,6 +296,19 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int } // Route to platform-specific test method + if account.IsCNProvider() { + switch account.GetAPIProtocol() { + case APIProtocolAdaptive: + return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt) + case APIProtocolResponses: + return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode)) + case APIProtocolChatCompletions: + return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt) + case APIProtocolAnthropic: + return s.testCNProviderAnthropicConnection(c, account, modelID) + } + } + if account.IsOpenAI() { return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode)) } @@ -304,6 +328,27 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testClaudeAccountConnection(c, account, modelID) } +func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = openai.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + baseURL := account.GetOpenAIBaseURL() + normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) + } + + return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) +} + // testClaudeAccountConnection tests an Anthropic Claude account's connection func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() @@ -610,10 +655,11 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account } // Align test routing with gateway behavior: OpenAI accounts apply normal - // account model mapping, and compact mode applies compact-only mapping on top. + // account model mapping. Native remote compaction v2 rides the ordinary + // /responses wire and does NOT apply the legacy compact-only mapping + // (post-#5641 semantics: compact_model_mapping is /responses/compact-only). testModelID = account.GetMappedModel(testModelID) if mode == AccountTestModeCompact { - testModelID = resolveOpenAICompactForwardModel(account, testModelID) return s.testOpenAICompactConnection(c, account, testModelID) } @@ -657,7 +703,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account apiURL = chatgptCodexAPIURL } else if credentialAccount.Type == "apikey" { // API Key - use Platform API - authToken = credentialAccount.GetOpenAIApiKey() + authToken = credentialAccount.GetOpenAIProtocolAPIKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } @@ -673,7 +719,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if !openai_compat.ShouldUseResponsesAPI(account.Extra) { return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) } - apiURL = buildOpenAIResponsesURL(normalizedBaseURL) + apiURL = buildOpenAIResponsesURLForPlatform(credentialAccount.Platform, normalizedBaseURL) } else { return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) } @@ -730,11 +776,12 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account req.Host = "chatgpt.com" req.Header.Set("accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("Originator", openai.CodexDefaultOriginator) + canonical := resolveCodexOutboundIdentity("") + req.Header.Set("Originator", canonical.originator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 @@ -751,7 +798,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } @@ -1976,8 +2023,10 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( return s.processOpenAIChatCompletionsStream(c, resp.Body) } -// testOpenAICompactConnection probes /responses/compact and persists the -// resulting capability state on the account. +// testOpenAICompactConnection probes native remote compaction v2 (streaming +// /responses with a compaction_trigger input item) and persists the resulting +// capability state on the account. The legacy unary /responses/compact +// endpoint has been sunset upstream (404, #5598/#5624) and is no longer probed. func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account *Account, testModelID string) error { ctx := c.Request.Context() credentialAccount := account @@ -2002,9 +2051,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account if authToken == "" && !credentialAccount.IsOpenAIAgentIdentity() { return s.sendErrorAndEnd(c, "No access token available") } - apiURL = chatgptCodexAPIURL + "/compact" + apiURL = chatgptCodexAPIURL case account.Type == AccountTypeAPIKey: - authToken = account.GetOpenAIApiKey() + authToken = account.GetOpenAIProtocolAPIKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } @@ -2016,7 +2065,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } - apiURL = appendOpenAIResponsesRequestPathSuffix(buildOpenAIResponsesURL(normalizedBaseURL), "/compact") + apiURL = buildOpenAIResponsesURLForPlatform(account.Platform, normalizedBaseURL) default: return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) } @@ -2027,7 +2076,11 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account c.Writer.Header().Set("X-Accel-Buffering", "no") c.Writer.Flush() - payloadBytes, _ := json.Marshal(createOpenAICompactProbePayload(testModelID)) + // 原生 v2 走普通 /responses 线:OAuth 与真实转发一致做上游模型归一化。 + if isOAuth { + testModelID = normalizeOpenAIModelForUpstream(credentialAccount, testModelID) + } + payloadBytes, _ := json.Marshal(createOpenAICompactProbePayload(testModelID, isOAuth)) if !agentIdentityTaskRecoveryWasTried(ctx) { s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) } @@ -2039,7 +2092,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") + // v2 探测是流式请求;同时补注协商头,与真实 codex 出站线型一致。 + req.Header.Set("Accept", "text/event-stream") + ensureOpenAIRemoteCompactionV2BetaFeature(req.Header) if credentialAccount.IsOpenAIAgentIdentity() { authHeaders, authErr := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount) if authErr != nil { @@ -2054,6 +2109,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account req.Header.Set("Authorization", "Bearer "+authToken) } applyOpenAICodexProbeHeaders(req.Header) + if isOAuth { + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + } probeSessionID := compactProbeSessionID(account.ID) req.Header.Set("Session_ID", probeSessionID) req.Header.Set("Conversation_ID", probeSessionID) @@ -2061,6 +2119,12 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account if isOAuth { req.Host = "chatgpt.com" setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) + // 指纹收敛:探测与真实转发走同一个 /responses 端点,身份也必须同构, + // 否则探测流量会以「缺 x-codex-installation-id + 非收敛 session」的 + // 形态暴露在上游眼里。账号关闭收敛(off)时返回 nil,探测保持原样。 + if fpIDs := resolveCodexFingerprintIDsFromRequest(account, req.Header); fpIDs != nil { + applyCodexFingerprintHeaders(req.Header, fpIDs) + } } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -2071,10 +2135,10 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { if s.accountRepo != nil { - updates := buildOpenAICompactProbeExtraUpdates(nil, nil, err, time.Now()) + updates := buildOpenAICompactProbeExtraUpdates(nil, nil, err, false, time.Now()) _ = s.accountRepo.UpdateExtra(ctx, account.ID, updates) mergeAccountExtra(account, updates) } @@ -2093,8 +2157,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account return s.testOpenAICompactConnection(c, account, testModelID) } + compactionFound := openAICompactProbeFoundCompactionItem(body) if s.accountRepo != nil { - updates := buildOpenAICompactProbeExtraUpdates(resp, body, nil, time.Now()) + updates := buildOpenAICompactProbeExtraUpdates(resp, body, nil, compactionFound, time.Now()) if codexUpdates, err := extractOpenAICodexProbeUpdates(resp); err == nil && len(codexUpdates) > 0 { updates = mergeExtraUpdates(updates, codexUpdates) } @@ -2116,7 +2181,11 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) } - s.sendEvent(c, TestEvent{Type: "content", Text: "Compact probe succeeded"}) + if !compactionFound { + return s.sendErrorAndEnd(c, "Upstream returned 2xx without a compaction output item (native remote compaction v2 unsupported on this chain)") + } + + s.sendEvent(c, TestEvent{Type: "content", Text: "Compact probe succeeded (native remote compaction v2)"}) s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } @@ -2246,11 +2315,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() - // 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview - testModelID := modelID - if testModelID == "" { - testModelID = "claude-sonnet-4-5" - } + testModelID := antigravityConnectionTestModel(modelID) if s.antigravityGatewayService == nil { return s.sendErrorAndEnd(c, "Antigravity gateway service not configured") @@ -2281,6 +2346,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac return nil } +func antigravityConnectionTestModel(modelID string) string { + if modelID == "" { + return defaultAntigravityTestModel + } + return modelID +} + // buildGeminiAPIKeyRequest builds request for Gemini API Key accounts func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { apiKey := account.GetCredential("api_key") @@ -2948,11 +3020,12 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("originator", openai.CodexDefaultOriginator) + canonical := resolveCodexOutboundIdentity("") + req.Header.Set("originator", canonical.originator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 @@ -2963,7 +3036,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, false) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error())) } @@ -3013,6 +3086,13 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co } func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) { + if event.Type == "test_complete" { + if suppress, ok := c.Get(accountTestSuppressCompletionContextKey); ok { + if suppressCompletion, _ := suppress.(bool); suppressCompletion { + return + } + } + } eventJSON, _ := json.Marshal(event) if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil { log.Printf("failed to write SSE event: %v", err) diff --git a/backend/internal/service/account_test_service_antigravity_test.go b/backend/internal/service/account_test_service_antigravity_test.go new file mode 100644 index 0000000000..b3362f3f69 --- /dev/null +++ b/backend/internal/service/account_test_service_antigravity_test.go @@ -0,0 +1,16 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAntigravityConnectionTestModel(t *testing.T) { + t.Parallel() + + require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel("")) + require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview")) +} diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go new file mode 100644 index 0000000000..8811fd840f --- /dev/null +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -0,0 +1,310 @@ +package service + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" +) + +const accountTestSuppressCompletionContextKey = "account_test_suppress_completion" + +// testCNProviderAdaptiveConnection verifies every native endpoint used by an +// adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus +// Anthropic; DeepSeek additionally uses its native Responses endpoint. +func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = openai.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + // The existing Chat probe owns the SSE lifecycle. Suppress intermediate + // completion events until every native adaptive endpoint has passed. + c.Set(accountTestSuppressCompletionContextKey, true) + defer c.Set(accountTestSuppressCompletionContextKey, false) + if err := s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt); err != nil { + return err + } + + if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil { + return err + } + + if account.Platform == PlatformDeepseek { + if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil { + return err + } + } + + c.Set(accountTestSuppressCompletionContextKey, false) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { + ctx := c.Request.Context() + baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Anthropic base URL: %s", err.Error())) + } + apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages" + + payload, err := createTestPayload(testModelID) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic test payload") + } + payloadBytes, _ := json.Marshal(payload) + + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /v1/messages 测试自适应 Anthropic 端点"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("anthropic-version", "2023-06-01") + for key, value := range claude.DefaultHeaders { + req.Header.Set(key, value) + } + req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Adaptive Anthropic endpoint returned %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + if err := s.processCNProviderAdaptiveAnthropicStream(c, resp.Body); err != nil { + return err + } + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /v1/messages 验证"}) + return nil +} + +func (s *AccountTestService) processCNProviderAdaptiveAnthropicStream(c *gin.Context, body io.Reader) error { + reader := bufio.NewReader(body) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return s.sendErrorAndEnd(c, "Adaptive Anthropic stream ended before message_stop") + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic stream read error: %s", err.Error())) + } + + line = strings.TrimSpace(line) + if line == "" || !sseDataPrefix.MatchString(line) { + continue + } + jsonStr := sseDataPrefix.ReplaceAllString(line, "") + if jsonStr == "[DONE]" { + return nil + } + + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + continue + } + switch eventType, _ := data["type"].(string); eventType { + case "content_block_delta": + if delta, ok := data["delta"].(map[string]any); ok { + if text, ok := delta["text"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) + } + } + case "message_stop": + return nil + case "error": + errorMsg := "Unknown error" + if errData, ok := data["error"].(map[string]any); ok { + if message, ok := errData["message"].(string); ok && message != "" { + errorMsg = message + } + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint error: %s", errorMsg)) + } + } +} + +func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { + ctx := c.Request.Context() + baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolResponses)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Responses base URL: %s", err.Error())) + } + apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL) + + payload := createOpenAITestPayload(testModelID, false) + // DeepSeek's native Responses endpoint is stateless and does not need the + // OpenAI probe's synthetic instructions. + delete(payload, "instructions") + payloadBytes, _ := json.Marshal(payload) + payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes) + + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /responses 测试自适应 Responses 端点"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Responses request") + } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+authToken) + applyOpenAICodexProbeHeaders(req.Header) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Responses endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Adaptive Responses endpoint returned %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + if err := s.processOpenAIStream(c, resp.Body); err != nil { + return err + } + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /responses 验证"}) + return nil +} + +func (s *AccountTestService) doCNProviderAdaptiveRequest(req *http.Request, account *Account) (*http.Response, error) { + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) +} + +// testCNProviderAnthropicConnection verifies the native Anthropic endpoint of a +// CN-provider account explicitly configured with api_protocol=anthropic. Before +// this path existed such accounts fell through to the generic Claude tester,// which (a) appended ?beta=true and (b) defaulted a missing base_url to +// https://api.anthropic.com — sending the provider's API key to Anthropic +// instead of the provider's own Anthropic-compatible endpoint. The probe uses +// GetAnthropicProtocolBaseURL (same resolution as real /v1/messages forwarding, +// including per-platform defaults) and the shared API-key auth header. +func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, account *Account, modelID string) error { + ctx := c.Request.Context() + + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = claude.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + baseURL, err := s.validateUpstreamBaseURL(account.GetAnthropicProtocolBaseURL()) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Anthropic base URL: %s", err.Error())) + } + if hint := cnAnthropicBaseURLMisconfigHint(baseURL); hint != "" { + return s.sendErrorAndEnd(c, hint) + } + apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages" + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + + payload, err := createTestPayload(testModelID) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Anthropic test payload") + } + payloadBytes, _ := json.Marshal(payload) + + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Anthropic test request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("anthropic-version", "2023-06-01") + for key, value := range claude.DefaultHeaders { + req.Header.Set(key, value) + } + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Anthropic endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Anthropic endpoint returned %d: %s", resp.StatusCode, string(body)) + if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + return s.processClaudeStream(c, resp.Body) +} + +// cnAnthropicBaseURLMisconfigHint reports an actionable error when an +// anthropic-protocol account's base_url still points at an OpenAI-compatible +// endpoint (paas path, version segment, or chat/completions / responses +// suffix). The naive {base}/v1/messages join would 404 (e.g. +// .../api/paas/v4/v1/messages) with no hint about the actual misconfiguration. +func cnAnthropicBaseURLMisconfigHint(baseURL string) string { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + path := strings.ToLower(strings.TrimRight(parsed.Path, "/")) + if path == "" { + return "" + } + openAICompatShaped := strings.Contains(path, "/paas/") || + strings.HasSuffix(path, "/chat/completions") || + strings.HasSuffix(path, "/responses") || + openAIBaseURLHasVersionSuffix(path) + if !openAICompatShaped { + return "" + } + return fmt.Sprintf( + "API protocol is anthropic but base_url (%s) looks like an OpenAI-compatible endpoint; "+ + "requests would hit {base}/v1/messages and 404. Set base_url to the provider's Anthropic endpoint "+ + "(e.g. https://open.bigmodel.cn/api/anthropic) or switch api_protocol to chat_completions/adaptive.", + baseURL, + ) +} diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go new file mode 100644 index 0000000000..410cc26261 --- /dev/null +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -0,0 +1,279 @@ +//go:build unit + +package service + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func adaptiveCNAccountTestAccount(id int64, platform string) *Account { + return &Account{ + ID: id, + Name: "adaptive-cn-test", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-adaptive-test", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "http://chat.example/v1", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }, + }, + } +} + +func adaptiveCNAccountTestService(account *Account, responses ...*http.Response) (*AccountTestService, *httpUpstreamRecorder) { + repo := &openAIAccountTestRepo{ + mockAccountRepoForGemini: mockAccountRepoForGemini{ + accountsByID: map[int64]*Account{account.ID: account}, + }, + } + upstream := &httpUpstreamRecorder{responses: responses} + return &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: rawChatCompletionsTestConfig(), + }, upstream +} + +func adaptiveCNChatTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"choices":[{"delta":{"content":"chat ok"},"finish_reason":"stop"}]} + +data: [DONE] + +`)), + } +} + +func adaptiveCNAnthropicTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"type":"content_block_delta","delta":{"text":"anthropic ok"}} + +data: {"type":"message_stop"} + +`)), + } +} + +func adaptiveCNResponsesTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"responses ok"} + +data: {"type":"response.completed"} + +`)), + } +} + +func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoints(t *testing.T) { + for index, testCase := range []struct { + name string + platform string + model string + }{ + {name: "Kimi", platform: PlatformKimi, model: "kimi-k2.5"}, + {name: "Zhipu", platform: PlatformZhipu, model: "glm-4.7"}, + } { + t.Run(testCase.name, func(t *testing.T) { + account := adaptiveCNAccountTestAccount(int64(301+index), testCase.platform) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 2) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String()) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization")) + require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`)) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证") + }) + } +} + +func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(302, PlatformDeepseek) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + adaptiveCNResponsesTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 3) + require.Equal(t, "http://responses.example/responses", upstream.requests[2].URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context())) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization")) + require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") +} + +func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(303, PlatformDeepseek) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + newJSONResponse(http.StatusNotFound, `{"error":{"message":"missing messages route"}}`), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Adaptive Anthropic endpoint returned 404") + require.Len(t, upstream.requests, 2) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_AdaptiveRejectsInvalidAnthropicSuccessBody(t *testing.T) { + account := adaptiveCNAccountTestAccount(305, PlatformKimi) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + newJSONResponse(http.StatusOK, `not an Anthropic stream`), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Adaptive Anthropic stream ended before message_stop") + require.Len(t, upstream.requests, 2) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_FixedCNChatProtocolStillTestsOnlyChatEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(304, PlatformZhipu) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://fixed-chat.example/v1" + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "http://fixed-chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) +} + +func anthropicProtocolCNAccount(id int64, platform string, credentials map[string]any) *Account { + base := map[string]any{ + "api_key": "sk-anthropic-test", + "api_protocol": APIProtocolAnthropic, + } + for key, value := range credentials { + base[key] = value + } + return &Account{ + ID: id, + Name: "anthropic-protocol-cn-test", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: base, + } +} + +func TestAccountTestService_AnthropicProtocolProbesNativeEndpointWithoutBetaQuery(t *testing.T) { + account := anthropicProtocolCNAccount(311, PlatformZhipu, map[string]any{ + "base_url": "https://open.bigmodel.cn/api/anthropic", + }) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + // Native Anthropic path without the ?beta=true suffix the generic Claude tester appends. + require.Equal(t, "https://open.bigmodel.cn/api/anthropic/v1/messages", req.URL.String()) + require.Empty(t, req.URL.RawQuery) + require.Equal(t, "sk-anthropic-test", req.Header.Get("x-api-key")) + require.Equal(t, "2023-06-01", req.Header.Get("anthropic-version")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_AnthropicProtocolFallsBackToProviderDefaultNotAnthropicDotCom(t *testing.T) { + // base_url intentionally absent: forwarding resolves the per-platform default + // Anthropic endpoint. The old fall-through probed https://api.anthropic.com + // with the provider's API key. + account := anthropicProtocolCNAccount(312, PlatformZhipu, nil) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, _ := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://open.bigmodel.cn/api/anthropic/v1/messages", upstream.requests[0].URL.String()) +} + +func TestAccountTestService_AnthropicProtocolRejectsOpenAICompatBaseURL(t *testing.T) { + account := anthropicProtocolCNAccount(313, PlatformZhipu, map[string]any{ + "base_url": "https://open.bigmodel.cn/api/paas/v4", + }) + svc, upstream := adaptiveCNAccountTestService(account) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.Error(t, err) + // Fails fast locally: no upstream request with the wrong endpoint shape. + require.Empty(t, upstream.requests) + require.Contains(t, recorder.Body.String(), "looks like an OpenAI-compatible endpoint") + require.Contains(t, recorder.Body.String(), "https://open.bigmodel.cn/api/anthropic") +} + +func TestAccountTestService_AnthropicProtocol401MarksAccountError(t *testing.T) { + account := anthropicProtocolCNAccount(314, PlatformKimi, map[string]any{ + "base_url": "https://api.moonshot.cn/anthropic", + }) + svc, _ := adaptiveCNAccountTestService( + account, + newJSONResponse(http.StatusUnauthorized, `{"error":{"message":"invalid key"}}`), + ) + c, _ := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Anthropic endpoint returned 401") + repo := svc.accountRepo.(*openAIAccountTestRepo) + require.Equal(t, account.ID, repo.setErrorID) +} diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index 5b973285a8..aa2b5f70bd 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -10,10 +10,16 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) +// compactProbeSSESuccessBody 是原生 v2 压缩成功的最小 SSE 形态: +// output_item.done 携带 compaction item + response.completed。 +const compactProbeSSESuccessBody = "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"id\":\"cmp_probe\",\"encrypted_content\":\"blob\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_probe\",\"output\":[]}}\n\n" + func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersistsSupport(t *testing.T) { gin.SetMode(gin.TestMode) @@ -38,8 +44,8 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi } upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-probe"}}, - Body: io.NopCloser(strings.NewReader(`{"id":"cmp_probe","status":"completed"}`)), + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-probe"}}, + Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)), }} svc := &AccountTestService{ accountRepo: repo, @@ -53,16 +59,22 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) require.NoError(t, err) - require.Equal(t, chatgptCodexAPIURL+"/compact", upstream.lastReq.URL.String()) + // 原生 v2:探测普通 /responses 线,不再打已下线的 /responses/compact。 + require.Equal(t, chatgptCodexAPIURL, upstream.lastReq.URL.String()) require.Equal(t, "chatgpt.com", upstream.lastReq.Host) - require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) - require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) + require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) + require.Contains(t, upstream.lastReq.Header.Get("x-codex-beta-features"), "remote_compaction_v2") require.NotEmpty(t, upstream.lastReq.Header.Get("Session_Id")) require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "chatgpt-acc", upstream.lastReq.Header.Get("chatgpt-account-id")) require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp")) require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool()) + inputItems := gjson.GetBytes(upstream.lastBody, "input").Array() + require.NotEmpty(t, inputItems) + require.Equal(t, "compaction_trigger", inputItems[len(inputItems)-1].Get("type").String()) updates := <-updateCalls require.Equal(t, true, updates["openai_compact_supported"]) @@ -114,7 +126,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuth404MarksUnsu require.Contains(t, rec.Body.String(), `"type":"error"`) } -func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompactPath(t *testing.T) { +func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesNativeResponsesPath(t *testing.T) { gin.SetMode(gin.TestMode) updateCalls := make(chan map[string]any, 1) @@ -127,8 +139,10 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact Schedulable: true, Concurrency: 1, Credentials: map[string]any{ - "api_key": "sk-test", - "base_url": "https://example.com/v1", + "api_key": "sk-test", + "base_url": "https://example.com/v1", + // post-#5641:compact_model_mapping 仅作用于 legacy /responses/compact, + // 原生 v2 探测不应用它。 "compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"}, }, } @@ -138,8 +152,8 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact } upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"id":"cmp_probe_apikey","status":"completed"}`)), + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)), }} svc := &AccountTestService{ accountRepo: repo, @@ -154,14 +168,16 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) require.NoError(t, err) - require.Equal(t, "https://example.com/v1/responses/compact", upstream.lastReq.URL.String()) + require.Equal(t, "https://example.com/v1/responses", upstream.lastReq.URL.String()) requireOpenAICodexProbeHeaders(t, upstream.lastReq.Header) - require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Contains(t, upstream.lastReq.Header.Get("x-codex-beta-features"), "remote_compaction_v2") + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String(), + "原生 v2 探测不应用 compact_model_mapping") updates := <-updateCalls require.Equal(t, true, updates["openai_compact_supported"]) } -func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBaseURLUsesV1Path(t *testing.T) { +func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBaseURLUsesResponsesPath(t *testing.T) { gin.SetMode(gin.TestMode) updateCalls := make(chan map[string]any, 1) @@ -183,8 +199,8 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBase } upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"id":"cmp_probe_apikey_default","status":"completed"}`)), + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)), }} svc := &AccountTestService{ accountRepo: repo, @@ -198,6 +214,117 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBase err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) require.NoError(t, err) - require.Equal(t, "https://api.openai.com/v1/responses/compact", upstream.lastReq.URL.String()) + require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String()) <-updateCalls } + +func TestAccountTestService_TestAccountConnection_OpenAICompact2xxWithoutItemMarksUnsupported(t *testing.T) { + gin.SetMode(gin.TestMode) + + updateCalls := make(chan map[string]any, 1) + account := Account{ + ID: 5, + Name: "openai-oauth-no-item", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + repo := &snapshotUpdateAccountRepo{ + stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + updateExtraCalls: updateCalls, + } + // 200 但流里没有 compaction item:链路吞掉了 compaction_trigger 的形态 + //(#5478 的 "got 0 items"),必须判定为不支持。 + noItemBody := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_1\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_x\",\"output\":[]}}\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(noItemBody)), + }} + svc := &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/5/test", bytes.NewReader(nil)) + + err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) + require.Error(t, err) + + updates := <-updateCalls + require.Equal(t, false, updates["openai_compact_supported"]) + require.Contains(t, rec.Body.String(), `"type":"error"`) +} + +// 探测与真实转发走同一 /responses 端点,出站身份必须与真实 Codex 同构: +// session/thread 为 UUID、携带 x-codex-installation-id(收敛账号用收敛值)。 +func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatchesRealTraffic(t *testing.T) { + gin.SetMode(gin.TestMode) + + updateCalls := make(chan map[string]any, 1) + account := Account{ + ID: 6, + Name: "openai-oauth-identity", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + // 收敛是显式 opt-in(#5610),这里显式开启以验证探测身份与真实流量同构。 + Extra: map[string]any{ + "codex_fingerprint_mode": "session", + codexFingerprintSeedExtraKey: testCodexFingerprintSeed, + }, + } + repo := &snapshotUpdateAccountRepo{ + stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + updateExtraCalls: updateCalls, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)), + }} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/6/test", bytes.NewReader(nil)) + + require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact)) + + // 显式 session 收敛模式:出站身份 = 账号级收敛值 + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + converged := resolveConvergedSessionID(seed) + require.Equal(t, converged, upstream.lastReq.Header.Get("session-id")) + require.Equal(t, converged, upstream.lastReq.Header.Get("session_id")) + require.Equal(t, resolveConvergedInstallationID(&account, seed), upstream.lastReq.Header.Get("x-codex-installation-id"), + "真实 Codex 每个请求必带 installation-id,探测不得缺失") + require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact", + "探测标识不得是可被上游一眼识别的字面量") + <-updateCalls +} + +func TestCompactProbeSessionID_IsUUIDShaped(t *testing.T) { + for _, id := range []int64{0, 1, 987654} { + got := compactProbeSessionID(id) + _, err := uuid.Parse(got) + require.NoError(t, err, "探测会话标识必须是 UUID 形态: %s", got) + } + require.Equal(t, compactProbeSessionID(7), compactProbeSessionID(7), "同账号应稳定复用同一会话") + require.NotEqual(t, compactProbeSessionID(7), compactProbeSessionID(8)) +} diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 2cd63ebe3f..dbd664059f 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -248,6 +248,113 @@ func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) { require.NotContains(t, recorder.Body.String(), `"success":true`) } +func TestAccountTestService_DeepSeekCustomBaseURLUsesV1ResponsesPath(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + +`)) + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 91, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://relay.example.com/v1", + "api_protocol": APIProtocolResponses, + }, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesSupported: true, + }, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://relay.example.com/v1/responses", upstream.requests[0].URL.String()) +} + +func TestAccountTestService_DeepSeekResponsesRoutesToOpenAIProbe(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + +`)) + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 93, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://relay.example.com/v1", + "api_protocol": APIProtocolResponses, + }, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesSupported: true, + }, + } + repo := &openAIAccountTestRepo{ + mockAccountRepoForGemini: mockAccountRepoForGemini{ + accountsByID: map[int64]*Account{93: account}, + }, + } + svc.accountRepo = repo + + err := svc.TestAccountConnection(ctx, account.ID, "gpt-5.4", "", "") + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://relay.example.com/v1/responses", upstream.requests[0].URL.String()) +} + +func TestAccountTestService_DeepSeekDefaultBaseURLUsesNativeResponsesPath(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + +`)) + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 92, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolResponses, + }, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesSupported: true, + }, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://api.deepseek.com/responses", upstream.requests[0].URL.String()) +} + func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := newTestContext() diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 084d153ff2..f49d3aaa71 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -105,15 +105,14 @@ type antigravityUsageCache struct { } const ( - apiCacheTTL = 3 * time.Minute - apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟 - antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误) - apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟 - windowStatsCacheTTL = 1 * time.Minute - openAIProbeCacheTTL = 10 * time.Minute - grokProbeRetryTTL = 1 * time.Minute - grokFreeQuotaWindow = 24 * time.Hour - openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移 + apiCacheTTL = 3 * time.Minute + apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟 + antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误) + apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟 + windowStatsCacheTTL = 1 * time.Minute + openAIProbeCacheTTL = 10 * time.Minute + grokProbeRetryTTL = 1 * time.Minute + grokFreeQuotaWindow = 24 * time.Hour ) // UsageCache 封装账户使用量相关的缓存 @@ -504,6 +503,13 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for return s.getUsageForAccount(ctx, account, forceProbe) } +// GetUsageForAccount 已加载账号的使用量直通入口(配额监控 fetcher 复用, +// 避免缓存未命中时账号被加载两次——每次 GetByID 含 proxies/groups 联查)。 +func (s *AccountUsageService) GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) { + forceProbe := len(force) > 0 && force[0] + return s.getUsageForAccount(ctx, account, forceProbe) +} + // GetUsageBatch 批量获取账号使用量。 // Anthropic OAuth/SetupToken 统一走 passive 链路,其他账号复用现有主动查询逻辑。 // 单个账号失败不会中断整批请求,错误会按账号返回。 @@ -723,6 +729,9 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou if updates := buildCodexSparkWindowExtraUpdates(quotaUsage, now); len(updates) > 0 { mergeAccountExtra(account, updates) s.persistOpenAICodexProbeSnapshot(account.ID, updates) + if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) + } if usage.UpdatedAt == nil { usage.UpdatedAt = &now } @@ -830,7 +839,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco if accessToken == "" && !account.IsOpenAIAgentIdentity() { return nil, fmt.Errorf("no access token available") } - modelID := openaipkg.DefaultTestModel + modelID := openaipkg.CodexUsageProbeModel payload := createOpenAITestPayload(modelID, true) payloadBytes, err := json.Marshal(payload) if err != nil { @@ -860,9 +869,10 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco } req.Header.Set("Accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("Originator", openaipkg.CodexDefaultOriginator) - req.Header.Set("Version", openAICodexProbeVersion) - req.Header.Set("User-Agent", codexCLIUserAgent) + canonical := resolveCodexOutboundIdentity("") + req.Header.Set("Originator", canonical.originator) + req.Header.Set("Version", canonical.version) + req.Header.Set("User-Agent", canonical.userAgent) if s.identityCache != nil { if fp, fpErr := s.identityCache.GetFingerprint(reqCtx, account.ID); fpErr == nil && fp != nil && strings.TrimSpace(fp.UserAgent) != "" { req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent)) @@ -914,7 +924,9 @@ func (s *AccountUsageService) persistOpenAICodexProbeSnapshot(accountID int64, u go func() { updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second) defer updateCancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { + notifyOpenAIAutoReset(accountID) + } }() } diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go index 15c5a66fa1..3843237e10 100644 --- a/backend/internal/service/account_wildcard_test.go +++ b/backend/internal/service/account_wildcard_test.go @@ -537,6 +537,52 @@ func TestAccountGetModelMapping_AntigravityEnsuresGeminiDefaultPassthroughs(t *t } } +func TestAccountGetModelMapping_GoogleOneUsesConservativeDefaults(t *testing.T) { + account := &Account{ + Platform: PlatformGemini, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "oauth_type": "google_one", + }, + } + + mapping := account.GetModelMapping() + for _, model := range []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"} { + if mapping[model] != model { + t.Fatalf("expected Google One model %q to map to itself, got %q", model, mapping[model]) + } + } + for _, model := range []string{"gemini-2.5-flash-image", "gemini-3.1-flash-image", "gemini-3.5-flash"} { + if _, ok := mapping[model]; ok { + t.Fatalf("did not expect unsupported Google One model %q", model) + } + } + if account.IsModelSupported("gemini-3.5-flash") { + t.Fatal("Google One defaults must not treat unsupported models as eligible") + } +} + +func TestAccountGetModelMapping_GoogleOnePreservesExplicitMapping(t *testing.T) { + account := &Account{ + Platform: PlatformGemini, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "oauth_type": "google_one", + "model_mapping": map[string]any{ + "custom-model": "gemini-2.5-flash", + }, + }, + } + + mapping := account.GetModelMapping() + if mapping["custom-model"] != "gemini-2.5-flash" { + t.Fatalf("expected explicit Google One mapping to be preserved, got %v", mapping) + } + if _, ok := mapping["gemini-2.5-flash"]; ok { + t.Fatalf("did not expect defaults to overwrite an explicit mapping: %v", mapping) + } +} + func TestAccountGetModelMapping_AntigravityRespectsWildcardOverride(t *testing.T) { account := &Account{ Platform: PlatformAntigravity, diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go new file mode 100644 index 0000000000..210caa0695 --- /dev/null +++ b/backend/internal/service/adaptive_api_protocol_test.go @@ -0,0 +1,227 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func adaptiveProtocolTestAccount(platform string, baseURLs map[string]any) *Account { + return &Account{ + ID: 701, + Name: "adaptive-cn", + Platform: platform, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAdaptive, + "account_mode": AccountModePayG, + "api_base_urls": baseURLs, + }, + } +} + +func adaptiveProtocolTestContext(path string, body []byte) *gin.Context { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c +} + +type cnProtocolIngressCase struct { + name string + path string + body []byte + forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error +} + +func cnProtocolIngressCases() []cnProtocolIngressCase { + return []cnProtocolIngressCase{ + { + name: "chat completions", + path: "/v1/chat/completions", + body: []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}],"stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "messages", + path: "/v1/messages", + body: []byte(`{"model":"deepseek-chat","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "responses", + path: "/v1/responses", + body: []byte(`{"model":"deepseek-chat","input":"hello","stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.Forward(context.Background(), c, account, body) + return err + }, + }, + } +} + +func TestAdaptiveProtocolRoutesChatCompletionsToNativeChat(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"glm-4.7","messages":[{"role":"user","content":"hello"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) +} + +func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"glm-4.7","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsAnthropic(context.Background(), adaptiveProtocolTestContext("/v1/messages", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.lastReq.URL.String()) + require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String()) +} + +func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"store":true,"previous_response_id":"resp_old","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }) + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool()) + require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) + require.Equal(t, int64(32), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) +} + +func TestFixedCNChatProtocolOverridesStaleResponsesMode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range cnProtocolIngressCases() { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, nil) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://chat.example" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + } + + err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body) + + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + }) + } +} + +func TestFixedCNResponsesProtocolOverridesStaleChatMode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range cnProtocolIngressCases() { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, nil) + account.Credentials["api_protocol"] = APIProtocolResponses + account.Credentials["base_url"] = "http://responses.example" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + + err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body) + + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + }) + } +} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 9958846658..ae194f61e1 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -128,22 +128,24 @@ var duplicateAccountDiscardedExtraKeys = map[string]struct{}{ "drive_storage_limit": {}, "drive_storage_usage": {}, "drive_tier_updated_at": {}, - "codex_primary_used_percent": {}, - "codex_primary_reset_after_seconds": {}, - "codex_primary_window_minutes": {}, - "codex_secondary_used_percent": {}, - "codex_secondary_reset_after_seconds": {}, - "codex_secondary_window_minutes": {}, - "codex_primary_over_secondary_percent": {}, - "codex_usage_updated_at": {}, - "codex_5h_used_percent": {}, - "codex_5h_reset_after_seconds": {}, - "codex_5h_window_minutes": {}, - "codex_5h_reset_at": {}, - "codex_7d_used_percent": {}, - "codex_7d_reset_after_seconds": {}, - "codex_7d_window_minutes": {}, - "codex_7d_reset_at": {}, + // Codex fingerprint convergence uses a per-account random seed, never copied from another account. + codexFingerprintSeedExtraKey: {}, + "codex_primary_used_percent": {}, + "codex_primary_reset_after_seconds": {}, + "codex_primary_window_minutes": {}, + "codex_secondary_used_percent": {}, + "codex_secondary_reset_after_seconds": {}, + "codex_secondary_window_minutes": {}, + "codex_primary_over_secondary_percent": {}, + "codex_usage_updated_at": {}, + "codex_5h_used_percent": {}, + "codex_5h_reset_after_seconds": {}, + "codex_5h_window_minutes": {}, + "codex_5h_reset_at": {}, + "codex_7d_used_percent": {}, + "codex_7d_reset_after_seconds": {}, + "codex_7d_window_minutes": {}, + "codex_7d_reset_at": {}, } func duplicateAccountExtra(value map[string]any) (map[string]any, error) { @@ -404,6 +406,7 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an delete(accountExtra, OllamaCloudUsageSessionExtraKey) delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey) delete(accountExtra, OllamaCloudUsageSnapshotExtraKey) + accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra) account := &Account{ Name: input.Name, Notes: normalizeAccountNotes(input.Notes), @@ -467,6 +470,10 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err != nil { return nil, err } + accountExtra, err = normalizeOpenAIAutoResetCreditExtra(input.Platform, input.Type, false, accountExtra) + if err != nil { + return nil, err + } // 绑定分组 groupIDs := input.GroupIDs @@ -556,6 +563,14 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + effectiveType := account.Type + if input.Type != "" { + effectiveType = input.Type + } + normalizedExtra, err = normalizeOpenAIAutoResetCreditExtra(account.Platform, effectiveType, account.IsShadow(), normalizedExtra) + if err != nil { + return nil, err + } } previousProbeIdentity := upstreamBillingProbeIdentity(account) previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account) @@ -646,11 +661,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U OllamaCloudUsageSessionExtraKey, OllamaCloudUsageAutoRefreshExtraKey, OllamaCloudUsageSnapshotExtraKey, + OpenAIAutoResetCreditStateExtraKey, } { if v, ok := account.Extra[key]; ok { normalizedExtra[key] = v } } + normalizedExtra = prepareCodexFingerprintExtraForUpdate(account, normalizedExtra) account.Extra = normalizedExtra if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 @@ -670,6 +687,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U ComputeQuotaResetAt(account.Extra) NormalizeFixedQuotaWindows(account.Extra) } + if input.Extra == nil { + account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra) + } if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate { if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate { return nil, infraerrors.BadRequest( @@ -852,6 +872,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U // UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 // (如 model_rate_limits / passive_usage_* 等)。 func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { + updates = sanitizedCodexFingerprintExtraUpdates(updates) + updates = stripOpenAIAutoResetCreditManagedExtra(updates, true) delete(updates, UpstreamBillingProbeEnabledExtraKey) delete(updates, UpstreamBillingRateSyncEnabledExtraKey) delete(updates, UpstreamBillingProbeExtraKey) @@ -877,6 +899,8 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd // It merges credentials/extra keys instead of overwriting the whole object. func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { // Managed probe/session state may only enter through dedicated typed endpoints. + input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra) + input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true) delete(input.Extra, UpstreamBillingProbeEnabledExtraKey) delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey) delete(input.Extra, UpstreamBillingProbeExtraKey) @@ -906,26 +930,36 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp return nil, err } } + openAISettings, err := normalizeBulkOpenAISettings(input) + if err != nil { + return nil, err + } needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck - _, hasLongContextBillingUpdate := input.Extra[openAILongContextBillingEnabledKey] // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 var cachedTargets []*Account - if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil || input.RateMultiplier != nil { + if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil { loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) if err != nil { return nil, err } cachedTargets = loaded } - if input.ProbeEnabled != nil { - targetsByID := make(map[int64]*Account, len(cachedTargets)) - for _, account := range cachedTargets { - if account != nil { - targetsByID[account.ID] = account - } + targetsByID := make(map[int64]*Account, len(cachedTargets)) + for _, account := range cachedTargets { + if account != nil { + targetsByID[account.ID] = account } + } + if openAISettings.any() { + inheritedCount, err := validateBulkOpenAISettingsTargets(input, openAISettings, targetsByID) + if err != nil { + return nil, err + } + result.LongContextInheritedCount = inheritedCount + } + if input.ProbeEnabled != nil { for _, accountID := range input.AccountIDs { account, ok := targetsByID[accountID] if !ok { @@ -936,18 +970,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp } } } - if hasLongContextBillingUpdate { - for _, account := range cachedTargets { - if account == nil || account.Platform != PlatformOpenAI { - continue - } - if err := ValidateOpenAILongContextBillingExtra(account.Platform, input.Extra); err != nil { - return nil, err - } - break - } - } - // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号 // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。 if len(input.Credentials) > 0 { @@ -1027,9 +1049,10 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp // Prepare bulk updates for columns and JSONB fields. repoUpdates := AccountBulkUpdate{ - Credentials: input.Credentials, - Extra: input.Extra, - ProbeEnabled: input.ProbeEnabled, + Credentials: input.Credentials, + Extra: input.Extra, + ProbeEnabled: input.ProbeEnabled, + EnsureCodexFingerprintSeed: ShouldEnsureCodexFingerprintSeedForExtraUpdates(input.Extra), } if input.ProbeEnabled != nil { if repoUpdates.Extra == nil { diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index ac6259509d..7b64e4b60f 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -267,7 +267,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue @@ -510,7 +510,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) - if group.Platform != PlatformOpenAI { + if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) @@ -890,7 +890,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.ReasoningEffortMappings = reasoningEffortMappings } sanitizeGroupMessagesDispatchFields(group) - if group.Platform != PlatformOpenAI { + if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) @@ -987,6 +987,12 @@ func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing) out[i] = pricing[i].Clone() out[i].ID = 0 out[i].ChannelID = 0 + if out[i].TimePricing != nil && len(out[i].TimePricing.Periods) > 0 { + return nil, infraerrors.BadRequest( + "GROUP_MODEL_TIME_PRICING_UNSUPPORTED", + "group model pricing does not support time pricing", + ) + } if strings.TrimSpace(out[i].Platform) == "" { out[i].Platform = platform } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 3030b945b9..be56680764 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -477,11 +477,12 @@ type UserGroupRPMStatus struct { // BulkUpdateAccountsResult is the aggregated response for bulk updates. type BulkUpdateAccountsResult struct { - Success int `json:"success"` - Failed int `json:"failed"` - SuccessIDs []int64 `json:"success_ids"` - FailedIDs []int64 `json:"failed_ids"` - Results []BulkUpdateAccountResult `json:"results"` + Success int `json:"success"` + Failed int `json:"failed"` + SuccessIDs []int64 `json:"success_ids"` + FailedIDs []int64 `json:"failed_ids"` + Results []BulkUpdateAccountResult `json:"results"` + LongContextInheritedCount int `json:"long_context_inherited_count,omitempty"` } type CreateProxyInput struct { diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go index 206efb21f1..e935beaa98 100644 --- a/backend/internal/service/admin_service_bulk_update_test.go +++ b/backend/internal/service/admin_service_bulk_update_test.go @@ -18,6 +18,8 @@ type accountRepoStubForBulkUpdate struct { accountRepoStub bulkUpdateErr error bulkUpdateIDs []int64 + bulkUpdateCalls int + lastBulkUpdate AccountBulkUpdate bindGroupErrByID map[int64]error bindGroupsCalls []int64 bindGroupsByAccount map[int64][]int64 @@ -50,14 +52,23 @@ type accountRepoStubForBulkUpdate struct { } } -func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) { +func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) { + s.bulkUpdateCalls++ s.bulkUpdateIDs = append([]int64{}, ids...) + s.lastBulkUpdate = updates if s.bulkUpdateErr != nil { return 0, s.bulkUpdateErr } return int64(len(ids)), nil } +func requireApplicationErrorReason(t *testing.T, err error, reason string) { + t.Helper() + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.Equal(t, reason, appErr.Reason) +} + func (s *accountRepoStubForBulkUpdate) Create(_ context.Context, account *Account) error { s.createAccount = account if s.createID > 0 { @@ -307,3 +318,280 @@ func TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters(t *testing.T) { require.Equal(t, 0, result.Failed) require.Equal(t, []int64{7, 11}, result.SuccessIDs) } + +func TestAdminServiceBulkUpdateAccounts_NormalizesOpenAISettings(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{ + {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + }} + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1, 2}, + Credentials: map[string]any{ + openAIEndpointCapabilitiesCredentialKey: []any{"chat_completions", "embeddings"}, + }, + Extra: map[string]any{ + openAILongContextBillingEnabledKey: true, + "openai_responses_mode": "auto", + }, + }) + + require.NoError(t, err) + require.Equal(t, 2, result.Success) + require.Zero(t, result.LongContextInheritedCount) + require.Equal(t, 1, repo.bulkUpdateCalls) + require.Contains(t, repo.lastBulkUpdate.Credentials, openAIEndpointCapabilitiesCredentialKey) + require.Nil(t, repo.lastBulkUpdate.Credentials[openAIEndpointCapabilitiesCredentialKey]) + require.Equal(t, true, repo.lastBulkUpdate.Extra[openAILongContextBillingEnabledKey]) + require.Contains(t, repo.lastBulkUpdate.Extra, "openai_responses_mode") + require.Nil(t, repo.lastBulkUpdate.Extra["openai_responses_mode"]) +} + +func TestAdminServiceBulkUpdateAccounts_AcceptsLongContextAccountTypes(t *testing.T) { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} { + t.Run(accountType, func(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{ + ID: 1, Platform: PlatformOpenAI, Type: accountType, + }}} + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Extra: map[string]any{openAILongContextBillingEnabledKey: false}, + }) + + require.NoError(t, err) + require.Equal(t, 1, result.Success) + require.Equal(t, 1, repo.bulkUpdateCalls) + }) + } +} + +func TestAdminServiceBulkUpdateAccounts_EmbeddingsOnlyResetsResponsesMode(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{ + {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + }} + svc := &adminServiceImpl{accountRepo: repo} + + _, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Credentials: map[string]any{ + openAIEndpointCapabilitiesCredentialKey: []string{"embeddings"}, + }, + }) + + require.NoError(t, err) + require.Equal(t, []string{"embeddings"}, repo.lastBulkUpdate.Credentials[openAIEndpointCapabilitiesCredentialKey]) + require.Contains(t, repo.lastBulkUpdate.Extra, "openai_responses_mode") + require.Nil(t, repo.lastBulkUpdate.Extra["openai_responses_mode"]) +} + +func TestAdminServiceBulkUpdateAccounts_RejectsInvalidOpenAISettingValuesBeforeWrite(t *testing.T) { + tests := []struct { + name string + credentials map[string]any + extra map[string]any + reason string + }{ + {name: "long context type", extra: map[string]any{openAILongContextBillingEnabledKey: "true"}, reason: "OPENAI_LONG_CONTEXT_BILLING_INVALID"}, + {name: "empty capabilities", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{}}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"}, + {name: "unknown capability", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{"responses"}}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"}, + {name: "capabilities type", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: "chat_completions"}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"}, + {name: "responses mode", extra: map[string]any{"openai_responses_mode": "sometimes"}, reason: "OPENAI_RESPONSES_MODE_INVALID"}, + {name: "responses type", extra: map[string]any{"openai_responses_mode": true}, reason: "OPENAI_RESPONSES_MODE_INVALID"}, + { + name: "embeddings conflict", + credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"}}, + extra: map[string]any{"openai_responses_mode": "force_responses"}, + reason: "OPENAI_RESPONSES_MODE_INVALID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{} + svc := &adminServiceImpl{accountRepo: repo} + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Credentials: tt.credentials, + Extra: tt.extra, + }) + require.Nil(t, result) + requireApplicationErrorReason(t, err, tt.reason) + require.Zero(t, repo.bulkUpdateCalls) + }) + } +} + +func TestAdminServiceBulkUpdateAccounts_RejectsInvalidOpenAITargetsBeforeWrite(t *testing.T) { + tests := []struct { + name string + accounts []*Account + input *BulkUpdateAccountsInput + }{ + { + name: "missing account", + accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}}, + input: &BulkUpdateAccountsInput{ + AccountIDs: []int64{1, 2}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }, + }, + { + name: "mixed platform long context", + accounts: []*Account{{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}}, + input: &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }, + }, + { + name: "oauth endpoint capabilities", + accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}}, + input: &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: nil}, + }, + }, + { + name: "unsupported OpenAI long context account type", + accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeServiceAccount}}, + input: &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: tt.accounts} + svc := &adminServiceImpl{accountRepo: repo} + result, err := svc.BulkUpdateAccounts(context.Background(), tt.input) + require.Nil(t, result) + requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID") + require.Zero(t, repo.bulkUpdateCalls) + }) + } +} + +func TestAdminServiceBulkUpdateAccounts_ForcedResponsesRequiresChatCapability(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"}, + }, + }}} + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Extra: map[string]any{"openai_responses_mode": "force_chat_completions"}, + }) + + require.Nil(t, result) + requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID") + require.Zero(t, repo.bulkUpdateCalls) +} + +func TestAdminServiceBulkUpdateAccounts_ForcedResponsesAcceptsChatCapabilityUpdate(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"}, + }, + }}} + svc := &adminServiceImpl{accountRepo: repo} + + _, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Credentials: map[string]any{ + openAIEndpointCapabilitiesCredentialKey: []any{"chat_completions"}, + }, + Extra: map[string]any{"openai_responses_mode": "force_responses"}, + }) + + require.NoError(t, err) + require.Equal(t, 1, repo.bulkUpdateCalls) +} + +func TestAdminServiceBulkUpdateAccounts_ReportsLongContextShadowInheritance(t *testing.T) { + parentID := int64(1) + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{ + {ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID}, + }} + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{parentID, 2}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }) + + require.NoError(t, err) + require.Equal(t, 1, result.LongContextInheritedCount) + require.Equal(t, 1, repo.bulkUpdateCalls) +} + +func TestAdminServiceBulkUpdateAccounts_RequiresParentForShadowOnlyLongContextUpdate(t *testing.T) { + parentID := int64(10) + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{ + {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID}, + {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID}, + }} + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1, 2}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }) + + require.Nil(t, result) + requireApplicationErrorReason(t, err, "OPENAI_LONG_CONTEXT_PARENT_REQUIRED") + require.Zero(t, repo.bulkUpdateCalls) +} + +func TestAdminServiceBulkUpdateAccounts_ShadowLongContextAllowsOtherUpdates(t *testing.T) { + parentID := int64(10) + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID, + }}} + svc := &adminServiceImpl{accountRepo: repo} + status := StatusDisabled + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Status: status, + Extra: map[string]any{openAILongContextBillingEnabledKey: false}, + }) + + require.NoError(t, err) + require.Equal(t, 1, result.LongContextInheritedCount) + require.Equal(t, 1, repo.bulkUpdateCalls) + require.NotNil(t, repo.lastBulkUpdate.Status) + require.Equal(t, status, *repo.lastBulkUpdate.Status) +} + +func TestAdminServiceBulkUpdateAccounts_ValidatesFilterResolvedOpenAITargets(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{ + listData: []Account{{ID: 7}}, + listResult: &pagination.PaginationResult{Total: 1}, + getByIDsAccounts: []*Account{{ID: 7, Platform: PlatformAnthropic, Type: AccountTypeOAuth}}, + } + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + Filters: &BulkUpdateAccountFilters{Platform: PlatformOpenAI}, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }) + + require.Nil(t, result) + requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID") + require.Equal(t, []int64{7}, repo.getByIDsIDs) + require.Zero(t, repo.bulkUpdateCalls) +} diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 5530720f5e..f1c00482b1 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/stretchr/testify/require" ) @@ -174,6 +175,13 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping "model_mapping": map[string]any{"gemini-custom": "gemini-2.5-flash"}, }, }, + { + ID: 3, + Platform: PlatformKimi, + Credentials: map[string]any{ + "model_mapping": map[string]any{"kimi-custom": "kimi-k2"}, + }, + }, }, } groupRepo := &groupRepoStubForAdmin{ @@ -188,6 +196,19 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping require.NoError(t, err) require.Contains(t, candidates, "gpt-custom") require.Contains(t, candidates, "gemini-custom") + require.Contains(t, candidates, "kimi-custom") require.Contains(t, candidates, "gpt-5.5") require.Contains(t, candidates, "gemini-2.5-flash") } + +// 独立 CN 分组的模型列表候选沿用 default 分支的 Claude 默认列表; +// composite 支持不得改变独立分组的候选语义。 +func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testing.T) { + want := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + want = append(want, model.ID) + } + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform) + } +} diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index c0b4faef52..8b0374e6be 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -4,8 +4,10 @@ package service import ( "context" + "net/http" "testing" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" ) @@ -156,6 +158,61 @@ func (s *groupRepoStubForAdmin) UpdateSortOrders(_ context.Context, _ []GroupSor return nil } +func TestAdminService_CreateGroup_RejectsTimePricing(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 51} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "time-pricing-group", + Platform: PlatformOpenAI, + RateMultiplier: 1, + ModelPricing: []ChannelModelPricing{{ + Platform: PlatformOpenAI, + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: validTimePricingForTest(), + }}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "GROUP_MODEL_TIME_PRICING_UNSUPPORTED", appErr.Reason) + require.Nil(t, repo.created) +} + +func TestAdminService_UpdateGroup_RejectsTimePricing(t *testing.T) { + existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + pricing := []ChannelModelPricing{{ + Platform: PlatformOpenAI, + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: validTimePricingForTest(), + }} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ModelPricing: &pricing}) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "GROUP_MODEL_TIME_PRICING_UNSUPPORTED", appErr.Reason) + require.Nil(t, repo.updated) +} + +func TestNormalizeGroupModelPricing_NormalizesEmptyTimePricing(t *testing.T) { + pricing, err := normalizeGroupModelPricing(PlatformOpenAI, []ChannelModelPricing{{ + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai"}, + }}) + + require.NoError(t, err) + require.Len(t, pricing, 1) + require.Nil(t, pricing[0].TimePricing) +} + type compositeRouteRepoStubForAdmin struct { routes []CompositeModelRoute created *CompositeModelRoute @@ -972,6 +1029,44 @@ func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatfo require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.created.MessagesDispatchModelConfig) } +func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "composite-group", + Platform: PlatformComposite, + RateMultiplier: 1.0, + AllowLive: true, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.created) + require.True(t, repo.created.AllowLive) +} + +func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "composite-group", + Platform: PlatformComposite, + Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + allowLive := true + + group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ + AllowLive: &allowLive, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.True(t, repo.updated.AllowLive) +} + func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChangesAwayFromOpenAI(t *testing.T) { existingGroup := &Group{ ID: 1, diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go index 7752d6fe3a..044d5be96c 100644 --- a/backend/internal/service/anthropic_apikey_auth.go +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -14,9 +14,14 @@ const ( // GetAnthropicAPIKeyAuthScheme returns the upstream authentication scheme for // Anthropic API-key accounts. Missing or invalid values keep the historical -// x-api-key behavior. +// x-api-key behavior. CN providers using their native Anthropic endpoints +// (api_protocol=anthropic) share the same override knob — Kimi/DeepSeek default +// to x-api-key, Zhipu can opt into Authorization: Bearer. func (a *Account) GetAnthropicAPIKeyAuthScheme() string { - if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey { + if a == nil || a.Type != AccountTypeAPIKey { + return AnthropicAPIKeyAuthSchemeXAPIKey + } + if a.Platform != PlatformAnthropic && !a.IsCNProvider() { return AnthropicAPIKeyAuthSchemeXAPIKey } diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index ab8f0d17a2..1d6d39e1aa 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -29,6 +29,8 @@ const ( AntigravityCredentialRejectedReason GatewayFailureReason = "antigravity_oauth_credential_rejected" ) +const antigravityCompatMaxTokens = 64000 + type antigravityCompatRequest struct { protocol antigravityCompatProtocol originalBody []byte @@ -158,7 +160,7 @@ func preserveChatCompletionTokenLimit(request *apicompat.ChatCompletionsRequest, limit = request.MaxCompletionTokens } if limit != nil && *limit > 0 { - claudeRequest.MaxTokens = *limit + claudeRequest.MaxTokens = min(*limit, antigravityCompatMaxTokens) } } @@ -264,6 +266,10 @@ func (s *AntigravityGatewayService) buildAntigravityCompatGeminiBody( if err != nil { return nil, err } + body, err = enableMixedGeminiToolInvocations(body) + if err != nil { + return nil, err + } body = ensureGeminiFunctionCallThoughtSignatures(body) body, err = injectIdentityPatchToGeminiRequest(body) if err != nil { @@ -280,6 +286,38 @@ func (s *AntigravityGatewayService) buildAntigravityCompatGeminiBody( return antigravity.TransformClaudeToGeminiWithOptions(claudeRequest, projectID, mappedModel, options) } +func enableMixedGeminiToolInvocations(body []byte) ([]byte, error) { + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + return nil, err + } + + var hasGoogleSearch, hasFunctionDeclarations bool + if tools, ok := request["tools"].([]any); ok { + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + _, hasSearch := tool["googleSearch"] + declarations, hasFunctions := tool["functionDeclarations"].([]any) + hasGoogleSearch = hasGoogleSearch || hasSearch + hasFunctionDeclarations = hasFunctionDeclarations || hasFunctions && len(declarations) > 0 + } + } + if !hasGoogleSearch || !hasFunctionDeclarations { + return body, nil + } + + toolConfig, _ := request["toolConfig"].(map[string]any) + if toolConfig == nil { + toolConfig = make(map[string]any) + request["toolConfig"] = toolConfig + } + toolConfig["includeServerSideToolInvocations"] = true + return json.Marshal(request) +} + func antigravityCompatProxyURL(account *Account) string { if account.ProxyID == nil || account.Proxy == nil { return "" diff --git a/backend/internal/service/antigravity_gateway_compat_test.go b/backend/internal/service/antigravity_gateway_compat_test.go index ff3d195cbf..a92b1654c2 100644 --- a/backend/internal/service/antigravity_gateway_compat_test.go +++ b/backend/internal/service/antigravity_gateway_compat_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -222,6 +224,51 @@ func TestAntigravityCompatRejectsUnsupportedAccountType(t *testing.T) { } } +func TestBuildAntigravityCompatGeminiBody_ConfiguresMixedToolInvocations(t *testing.T) { + svc := &AntigravityGatewayService{} + tests := []struct { + name string + tools string + wantField bool + }{ + { + name: "mixed server and client tools", + tools: `[{"name":"get_weather","input_schema":{"type":"object"}},{"type":"web_search_20250305","name":"web_search"}]`, + wantField: true, + }, + { + name: "client tools only", + tools: `[{"name":"get_weather","input_schema":{"type":"object"}}]`, + }, + { + name: "server tools only", + tools: `[{"type":"web_search_20250305","name":"web_search"}]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claudeBody := []byte(`{"messages":[{"role":"user","content":"hello"}],"tools":` + tt.tools + `}`) + claudeBody = bytes.ReplaceAll(claudeBody, []byte{92}, nil) + body, err := svc.buildAntigravityCompatGeminiBody(context.Background(), claudeBody, nil, "project-1", "gemini-2.5-flash") + require.NoError(t, err) + + var wrapped map[string]any + require.NoError(t, json.Unmarshal(body, &wrapped)) + request, ok := wrapped["request"].(map[string]any) + require.True(t, ok) + toolConfig, exists := request["toolConfig"].(map[string]any) + if !tt.wantField { + require.False(t, exists) + return + } + require.True(t, exists) + require.Equal(t, true, toolConfig["includeServerSideToolInvocations"]) + require.NotContains(t, toolConfig, "include_server_side_tool_invocations") + }) + } +} + func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { @@ -239,6 +286,21 @@ func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) { body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8,"max_completion_tokens":13}`, want: 13, }, + { + name: "max_tokens at safe ceiling is preserved", + body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":64000}`, + want: 64000, + }, + { + name: "max_tokens above safe ceiling is clamped", + body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":64001}`, + want: 64000, + }, + { + name: "precedence applies before clamping", + body: `{"model":"gemini-3.1-pro-high","messages":[{"role":"user","content":"ok"}],"max_tokens":8,"max_completion_tokens":64001}`, + want: 64000, + }, } for _, tt := range tests { @@ -264,6 +326,27 @@ func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) { } } +func TestPreserveChatCompletionTokenLimitIgnoresAbsentAndNonPositiveValues(t *testing.T) { + tests := []struct { + name string + request apicompat.ChatCompletionsRequest + }{ + {name: "absent"}, + {name: "zero max_tokens", request: apicompat.ChatCompletionsRequest{MaxTokens: antigravityCompatIntPtr(0)}}, + {name: "negative max_completion_tokens takes precedence", request: apicompat.ChatCompletionsRequest{MaxTokens: antigravityCompatIntPtr(12), MaxCompletionTokens: antigravityCompatIntPtr(-1)}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claudeRequest := &apicompat.AnthropicRequest{MaxTokens: 99} + preserveChatCompletionTokenLimit(&tt.request, claudeRequest) + require.Equal(t, 99, claudeRequest.MaxTokens) + }) + } +} + +func antigravityCompatIntPtr(v int) *int { return &v } + func TestAntigravityCompatRoutesByMappedModelFamily(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { diff --git a/backend/internal/service/antigravity_gateway_retry.go b/backend/internal/service/antigravity_gateway_retry.go index f2f5d44f84..5e16ba8692 100644 --- a/backend/internal/service/antigravity_gateway_retry.go +++ b/backend/internal/service/antigravity_gateway_retry.go @@ -48,17 +48,14 @@ type antigravityRetryLoopResult struct { // resolveAntigravityForwardBaseURL 解析转发用 base URL。 // -// 默认使用生产端点 cloudcode-pa.googleapis.com(antigravity.BaseURLs 的首个地址, -// 与账号 OAuth 登录/测试连接所用的 antigravity.BaseURL 一致)。 +// 显式环境变量优先。未配置时,LoadCodeAssist 返回 paidTier 的付费账号使用 +// daily 端点,其他账号继续使用生产端点,避免免费账号的 OAuth token 出现 401。 // // 历史上这里改用 ForwardBaseURLs()(把 daily/sandbox 排到首位)并默认取首个地址, // 导致网关把带生产 OAuth token 的请求发到 daily-cloudcode-pa.sandbox.googleapis.com, // 上游拒绝 → 账号被 401「Invalid bearer token」/502 打入临时不可调度且无法恢复 // (见 #3611 / #2962)。后台「测试连接」用的是生产端点,所以「测试成功但网关 401」。 -// -// daily/sandbox 端点仅供内部联调,需显式设置 -// GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=daily(或 sandbox)才启用。 -func resolveAntigravityForwardBaseURL() string { +func resolveAntigravityForwardBaseURL(account *Account) string { baseURLs := antigravity.BaseURLs if len(baseURLs) == 0 { return "" @@ -67,9 +64,28 @@ func resolveAntigravityForwardBaseURL() string { if (mode == "daily" || mode == "sandbox") && len(baseURLs) > 1 { return baseURLs[1] } + if mode == "" && accountHasAntigravityPaidTier(account) && len(baseURLs) > 1 { + return baseURLs[1] + } return baseURLs[0] } +func accountHasAntigravityPaidTier(account *Account) bool { + if account == nil || account.Credentials == nil { + return false + } + planType, ok := account.Credentials["plan_type"].(string) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(planType)) { + case "pro", "ultra": + return true + default: + return false + } +} + // smartRetryAction 智能重试的处理结果 type smartRetryAction int @@ -488,7 +504,7 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP } } - baseURL := resolveAntigravityForwardBaseURL() + baseURL := resolveAntigravityForwardBaseURL(p.account) if baseURL == "" { return nil, errors.New("no antigravity forward base url configured") } diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 00fee73a11..c463a870e5 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -340,6 +340,103 @@ func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t require.Equal(t, "configured-project", wrapped["project"]) } +func TestAntigravityGatewayService_ForwardGemini_ImageUsesDefaultMappingAndOAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + body := []byte(`{"contents":[{"role":"user","parts":[{"text":"draw a cat"}]}],"generationConfig":{"responseModalities":["TEXT","IMAGE"],"imageConfig":{"aspectRatio":"1:1"}}}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.1-flash-image:generateContent", bytes.NewReader(body)) + + upstream := &queuedHTTPUpstreamStub{ + responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"aGVsbG8=\"}}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n", + )), + }}, + onCall: func(req *http.Request, _ *queuedHTTPUpstreamStub) { + require.Equal(t, "Bearer test-access-token", req.Header.Get("Authorization")) + require.Equal(t, "application/json", req.Header.Get("Content-Type")) + require.Contains(t, req.URL.String(), "/v1internal:streamGenerateContent?alt=sse") + }, + } + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 104, + Name: "antigravity-image", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "test-access-token", + "project_id": "test-project", + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-3.1-flash-image", "generateContent", true, body, false) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gemini-3.1-flash-image", result.Model) + require.Equal(t, "gemini-3.1-flash-image", result.UpstreamModel) + require.Equal(t, 1, result.ImageCount) + require.Len(t, upstream.requestBodies, 1) + + var wrapped map[string]any + require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped)) + require.Equal(t, "test-project", wrapped["project"]) + require.Equal(t, "gemini-3.1-flash-image", wrapped["model"]) + request, ok := wrapped["request"].(map[string]any) + require.True(t, ok) + generationConfig, ok := request["generationConfig"].(map[string]any) + require.True(t, ok) + require.Equal(t, []any{"TEXT", "IMAGE"}, generationConfig["responseModalities"]) +} + +func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocationConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + body = bytes.ReplaceAll(body, []byte{92}, nil) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body)) + + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{}}}\n\n")), + }}} + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 103, Name: "native-gemini", Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 1, + Credentials: map[string]any{"access_token": "token", "project_id": "project-103", "model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}}, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requestBodies, 1) + + var wrapped map[string]any + require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped)) + request, ok := wrapped["request"].(map[string]any) + require.True(t, ok) + toolConfig, ok := request["toolConfig"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, toolConfig["includeServerSideToolInvocations"]) + require.NotContains(t, toolConfig, "include_server_side_tool_invocations") +} + func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) { gin.SetMode(gin.TestMode) writer := httptest.NewRecorder() diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 62ff2e295d..61cc7e31d6 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -69,10 +69,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-5", + name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5-20250929", accountMapping: nil, - expected: "claude-sonnet-4-5", + expected: "claude-sonnet-4-6", }, // 3. 默认映射中的透传(映射到自己) @@ -89,7 +89,7 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射透传 - claude-sonnet-4-5", + name: "显式 canonical 选择 - claude-sonnet-4-5 透传", requestedModel: "claude-sonnet-4-5", accountMapping: nil, expected: "claude-sonnet-4-5", @@ -113,10 +113,19 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-opus-4-6-thinking", }, { - name: "默认映射透传 - claude-sonnet-4-5-thinking", + name: "默认映射 - claude-sonnet-4-5-thinking → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5-thinking", accountMapping: nil, - expected: "claude-sonnet-4-5-thinking", + expected: "claude-sonnet-4-6", + }, + { + name: "账户显式目标只映射一步 - custom-sonnet → claude-sonnet-4-5", + requestedModel: "custom-sonnet", + accountMapping: map[string]string{ + "custom-sonnet": "claude-sonnet-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + expected: "claude-sonnet-4-5", }, { name: "默认映射透传 - gemini-2.5-flash", diff --git a/backend/internal/service/antigravity_quota_fetcher.go b/backend/internal/service/antigravity_quota_fetcher.go index 9e09c90443..e296654e6d 100644 --- a/backend/internal/service/antigravity_quota_fetcher.go +++ b/backend/internal/service/antigravity_quota_fetcher.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) @@ -28,11 +29,12 @@ const ( // AntigravityQuotaFetcher 从 Antigravity API 获取额度 type AntigravityQuotaFetcher struct { proxyRepo ProxyRepository + cfg *config.Config } // NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher -func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository) *AntigravityQuotaFetcher { - return &AntigravityQuotaFetcher{proxyRepo: proxyRepo} +func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository, cfg *config.Config) *AntigravityQuotaFetcher { + return &AntigravityQuotaFetcher{proxyRepo: proxyRepo, cfg: cfg} } // CanFetch 检查是否可以获取此账户的额度 @@ -55,7 +57,7 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou } // 调用 API 获取配额 - modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID) + modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID, resolveModelsListReadLimit(f.cfg)) if err != nil { // 403 Forbidden: 不报错,返回 is_forbidden 标记 var forbiddenErr *antigravity.ForbiddenError diff --git a/backend/internal/service/antigravity_quota_fetcher_test.go b/backend/internal/service/antigravity_quota_fetcher_test.go index e0f5705141..a5463ec007 100644 --- a/backend/internal/service/antigravity_quota_fetcher_test.go +++ b/backend/internal/service/antigravity_quota_fetcher_test.go @@ -3,11 +3,16 @@ package service import ( + "context" "errors" + "net/http" + "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) @@ -390,6 +395,36 @@ func TestBuildUsageInfo_AICredits(t *testing.T) { require.Equal(t, 5.0, info.AICredits[0].MinimumBalance) } +func TestFetchQuotaUsesConfiguredModelsListBodyLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`)) + })) + defer server.Close() + + oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) + oldAvailability := antigravity.DefaultURLAvailability + t.Cleanup(func() { + antigravity.BaseURLs = oldBaseURLs + antigravity.DefaultURLAvailability = oldAvailability + }) + antigravity.BaseURLs = []string{server.URL} + antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute) + + cfg := &config.Config{} + cfg.Gateway.ModelsListReadMaxBytes = 8 + fetcher := NewAntigravityQuotaFetcher(nil, cfg) + _, err := fetcher.FetchQuota(context.Background(), &Account{ + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token", + "project_id": "project", + }, + }, "") + require.ErrorContains(t, err, "响应超过 8 字节") +} + func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) { // 模拟 FetchQuota 遇到 403 时的行为: // FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index 374b29f610..d632f14bda 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -844,17 +844,28 @@ func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) { require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey) } -func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing.T) { +func TestSetAntigravityModelRateLimits_DoesNotDoubleMapCustomChain(t *testing.T) { repo := &stubAntigravityAccountRepo{} svc := &AntigravityGatewayService{} - account := &Account{ID: 790, Platform: PlatformAntigravity} + account := &Account{ + ID: 790, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "custom-sonnet": "claude-sonnet-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + }, + } resetAt := time.Now().Add(30 * time.Second) + canonicalModel := resolveFinalAntigravityModelKey(context.Background(), account, "custom-sonnet") + require.Equal(t, "claude-sonnet-4-5", canonicalModel) success := svc.setAntigravityModelRateLimits( context.Background(), repo, account, - "claude-sonnet-4-5", + canonicalModel, "[test]", 429, resetAt, @@ -866,6 +877,33 @@ func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) } +func TestSetModelRateLimitAndClearSession_UsesUpstreamReportedModelMetadata(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + account := &Account{ + ID: 791, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + }, + } + + svc.setModelRateLimitAndClearSession(&handleModelRateLimitParams{ + ctx: context.Background(), + prefix: "[test]", + account: account, + statusCode: 429, + }, &antigravitySmartRetryInfo{ + RetryDelay: 30 * time.Second, + ModelName: "claude-sonnet-4-5", + }) + + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) +} + func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) { upstream := &recordingOKUpstream{} account := &Account{ @@ -1009,9 +1047,7 @@ func TestIsAntigravityAccountSwitchError(t *testing.T) { } } -func TestResolveAntigravityForwardBaseURL_DefaultDaily(t *testing.T) { - t.Setenv(antigravityForwardBaseURLEnv, "") - +func TestResolveAntigravityForwardBaseURL(t *testing.T) { oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) defer func() { antigravity.BaseURLs = oldBaseURLs @@ -1019,10 +1055,46 @@ func TestResolveAntigravityForwardBaseURL_DefaultDaily(t *testing.T) { prodURL := "https://prod.test" dailyURL := "https://daily.test" - antigravity.BaseURLs = []string{dailyURL, prodURL} + antigravity.BaseURLs = []string{prodURL, dailyURL} - resolved := resolveAntigravityForwardBaseURL() - require.Equal(t, dailyURL, resolved) + tests := []struct { + name string + env string + account *Account + want string + }{ + { + name: "pro defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": " Pro "}}, + want: dailyURL, + }, + { + name: "ultra defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "ULTRA"}}, + want: dailyURL, + }, + {name: "free defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free"}}, want: prodURL}, + {name: "abnormal defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "Abnormal"}}, want: prodURL}, + {name: "unknown defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "enterprise"}}, want: prodURL}, + {name: "malformed defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": map[string]any{"name": "pro"}}}, want: prodURL}, + {name: "missing defaults to prod", account: &Account{Credentials: map[string]any{}}, want: prodURL}, + {name: "nil account defaults to prod", account: nil, want: prodURL}, + { + name: "daily override wins for free tier", env: " daily ", + account: &Account{Credentials: map[string]any{"plan_type": "free"}}, + want: dailyURL, + }, + { + name: "prod override wins for paid tier", env: " PROD ", + account: &Account{Credentials: map[string]any{"plan_type": "pro"}}, + want: prodURL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, tt.env) + require.Equal(t, tt.want, resolveAntigravityForwardBaseURL(tt.account)) + }) + } } func TestAntigravityAccountSwitchError_Error(t *testing.T) { diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 5fdc96b793..30a41eece6 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -84,6 +84,8 @@ type APIKeyAuthGroupSnapshot struct { AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"` AudioTTSPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` AudioSTTPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` + LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` + ModelPricing []ChannelModelPricing `json:"model_pricing,omitempty"` ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 696f21bb25..7cb37baf48 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 19 // v19: group search/audio/video_model_prices billing fields (force refresh of pre-fix snapshots) +const apiKeyAuthSnapshotVersion = 20 // v20: group long-context and model pricing fields (force refresh of pre-fix snapshots) type apiKeyAuthCacheConfig struct { l1Size int @@ -406,6 +406,8 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) AudioRealtimePricePerMin: apiKey.Group.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: apiKey.Group.AudioTTSPricePerMillionChars, AudioSTTPricePerHour: apiKey.Group.AudioSTTPricePerHour, + LongContextPricingEnabled: apiKey.Group.LongContextPricingEnabled, + ModelPricing: apiKey.Group.ModelPricing, ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly, FallbackGroupID: apiKey.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest, @@ -501,6 +503,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho AudioRealtimePricePerMin: snapshot.Group.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: snapshot.Group.AudioTTSPricePerMillionChars, AudioSTTPricePerHour: snapshot.Group.AudioSTTPricePerHour, + LongContextPricingEnabled: snapshot.Group.LongContextPricingEnabled, + ModelPricing: snapshot.Group.ModelPricing, ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly, FallbackGroupID: snapshot.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/service/api_key_auth_cache_pricing_test.go b/backend/internal/service/api_key_auth_cache_pricing_test.go new file mode 100644 index 0000000000..22aa636aa7 --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_pricing_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAPIKeyAuthSnapshotGroupPricingRoundtrip(t *testing.T) { + groupID := int64(50) + inputPrice := 1e-6 + outputPrice := 2e-6 + apiKey := &APIKey{ + ID: 82, UserID: 40, GroupID: &groupID, Key: "sk-pricing-roundtrip", Status: StatusActive, + User: &User{ID: 40, Status: StatusActive}, + Group: &Group{ + ID: groupID, Name: "pricing-roundtrip", Platform: PlatformAnthropic, Status: StatusActive, + LongContextPricingEnabled: true, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, + }}, + }, + } + svc := &APIKeyService{} + + payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)}) + require.NoError(t, err) + var cached APIKeyAuthCacheEntry + require.NoError(t, json.Unmarshal(payload, &cached)) + + materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached) + require.NoError(t, err) + require.True(t, used) + require.NotNil(t, materialized.Group) + require.True(t, materialized.Group.LongContextPricingEnabled) + require.Equal(t, apiKey.Group.ModelPricing, materialized.Group.ModelPricing) + + billing := &BillingService{fallbackPrices: map[string]*ModelPricing{ + "claude-sonnet-4": {InputPricePerToken: 3e-6, OutputPricePerToken: 15e-6}, + }} + resolver := NewModelPricingResolver(nil, billing) + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", Group: materialized.Group}) + require.Equal(t, PricingSourceGroup, resolved.Source) + require.True(t, resolved.longContextPricingEnabled) + require.InDelta(t, inputPrice, resolved.BasePricing.InputPricePerToken, 1e-12) + require.InDelta(t, outputPrice, resolved.BasePricing.OutputPricePerToken, 1e-12) +} diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index 40519f2896..fbcd96a42a 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 19, snapshot.Version, "v19 起认证快照携带 search/audio/video_model_prices 计费字段") + require.Equal(t, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) diff --git a/backend/internal/service/auth_email_binding.go b/backend/internal/service/auth_email_binding.go index 4fe3c4e85b..733e76b7fd 100644 --- a/backend/internal/service/auth_email_binding.go +++ b/backend/internal/service/auth_email_binding.go @@ -56,12 +56,8 @@ func (s *AuthService) BindEmailIdentity( return nil, ErrPasswordIncorrect } - existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail) - switch { - case err == nil && existingUser != nil && existingUser.ID != userID: - return nil, ErrEmailExists - case err != nil && !errors.Is(err, ErrUserNotFound): - return nil, ErrServiceUnavailable + if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil { + return nil, err } hashedPassword, err := s.HashPassword(password) @@ -115,19 +111,16 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6 if s.emailService == nil { return ErrServiceUnavailable } - if _, err := s.userRepo.GetByID(ctx, userID); err != nil { + currentUser, err := s.userRepo.GetByID(ctx, userID) + if err != nil { if errors.Is(err, ErrUserNotFound) { return ErrUserNotFound } return ErrServiceUnavailable } - existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail) - switch { - case err == nil && existingUser != nil && existingUser.ID != userID: - return ErrEmailExists - case err != nil && !errors.Is(err, ErrUserNotFound): - return ErrServiceUnavailable + if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil { + return err } siteName := "Sub2API" @@ -137,6 +130,45 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6 return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale)) } +// ensureEmailIdentityAvailableForUser 在发码 / 提交换绑前做快速查重。 +// 精确地址或 provider alias 若已指向其他用户的收件箱则直接拒绝; +// 当前用户自己的收件箱允许继续,便于其更换自身的 alias 变体。 +func (s *AuthService) ensureEmailIdentityAvailableForUser( + ctx context.Context, + currentUser *User, + email string, +) error { + if currentUser == nil { + return ErrUserNotFound + } + + existingUser, err := s.userRepo.GetByEmail(ctx, email) + switch { + case err == nil: + if existingUser == nil || existingUser.ID == currentUser.ID { + break + } + return ErrEmailExists + case errors.Is(err, ErrUserNotFound): + // Continue to alias lookup below. + default: + return ErrServiceUnavailable + } + + if NormalizeEmailForAliasDedup(currentUser.Email) == NormalizeEmailForAliasDedup(email) { + return nil + } + + aliasExists, err := s.userRepo.ExistsByEmailAlias(ctx, email) + if err != nil { + return ErrServiceUnavailable + } + if aliasExists { + return ErrEmailExists + } + return nil +} + func normalizeEmailForIdentityBinding(email string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(email)) if normalized == "" || len(normalized) > 255 { @@ -153,6 +185,12 @@ func hasBindableEmailIdentitySubject(email string) bool { return normalized != "" && !isReservedEmail(normalized) } +// emailIdentityAliasGuardRepository 是主邮箱替换所需的事务内原子仓储能力, +// 用于关闭服务层前置查重与实际写入之间的并发窗口。 +type emailIdentityAliasGuardRepository interface { + UpdateEmailWithAliasGuard(ctx context.Context, userID int64, email string, passwordHash string) error +} + func (s *AuthService) updateBoundEmailIdentityTx( ctx context.Context, currentUser *User, @@ -192,16 +230,15 @@ func (s *AuthService) updateBoundEmailIdentityWithClient( return ErrServiceUnavailable } - oldEmail := currentUser.Email - if _, err := client.User.UpdateOneID(currentUser.ID). - SetEmail(email). - SetPasswordHash(hashedPassword). - Save(ctx); err != nil { - if dbent.IsConstraintError(err) { - return ErrEmailExists - } + guard, ok := s.userRepo.(emailIdentityAliasGuardRepository) + if !ok { return ErrServiceUnavailable } + if err := guard.UpdateEmailWithAliasGuard(ctx, currentUser.ID, email, hashedPassword); err != nil { + return err + } + + oldEmail := currentUser.Email if err := replaceBoundEmailAuthIdentityWithClient(ctx, client, currentUser.ID, oldEmail, email, "auth_service_email_bind"); err != nil { if errors.Is(err, ErrEmailExists) { diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 2beba0d402..f17cfbb4d7 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -245,13 +245,15 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw Status: StatusActive, } - if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil { + if err := s.createUserAndClaimInvitation(ctx, user, invitationRedeemCode); err != nil { // 优先检查邮箱冲突错误(竞态条件下可能发生) switch { case errors.Is(err, ErrEmailExists): return "", nil, ErrEmailExists case errors.Is(err, ErrEmailDomainRegistrationLimit): return "", nil, ErrEmailDomainRegistrationLimit + case errors.Is(err, ErrInvitationCodeInvalid): + return "", nil, ErrInvitationCodeInvalid default: logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err) return "", nil, ErrServiceUnavailable @@ -273,13 +275,8 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } } - // 标记邀请码为已使用(如果使用了邀请码) - if invitationRedeemCode != nil { - if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { - // 邀请码标记失败不影响注册,只记录日志 - logger.LegacyPrintf("service.auth", "[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err) - } - } + // 邀请码占用已由 createUserAndClaimInvitation 在“用户创建 + 邀请码占用”的 + // 同一个数据库事务内原子完成(一次性约束,见函数注释),此处不再单独标记。 // 应用优惠码(如果提供且功能已启用) if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) { if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { @@ -1274,6 +1271,64 @@ func (s *AuthService) createUserWithRegistrationEmailGuard(ctx context.Context, return quotaRepo.CreateWithEmailAliasGuardAndDomainLimit(ctx, user, domain) } +// createUserAndClaimInvitation 原子化完成“用户创建 + 邀请码占用”。 +// +// 背景:邀请码属于一次性凭证,必须保证“一个邀请码最多注册一个账号”。旧实现先检查 +// CanUse()、再创建用户、最后才 redeemRepo.Use()(且失败仅记日志),检查与消耗分离且 +// 不在同一事务,并发注册可在同一邀请码上同时通过检查并各自创建账号(TOCTOU 竞态)。 +// +// 本实现把两者放入同一个数据库事务: +// - 占用走 redeemRepo.Use 的条件更新(WHERE status='unused',乐观锁); +// - 并发下只有一个事务能占用成功,其余事务回滚——既不产生多余账号,也不让码被烧掉; +// - 事务回滚同时撤销用户创建,避免“账号已建、码被占用”的中间态。 +// +// 无邀请码时保持原单次创建路径(不开事务);entClient 缺失的异常配置下退化为顺序执行, +// 并发正确性仍由 Use 的条件更新兜底(可能产生孤儿用户,但不会放行第二个注册)。 +func (s *AuthService) createUserAndClaimInvitation(ctx context.Context, user *User, invitation *RedeemCode) error { + commitUser := func(execCtx context.Context) error { + if err := s.createUserWithRegistrationEmailGuard(execCtx, user); err != nil { + return err + } + if invitation == nil { + return nil + } + // createUserWithRegistrationEmailGuard 会回填 user.ID(applyUserEntityToService), + // 直接以其原子占用邀请码;占用失败即整体回滚(含用户创建,见 user_repo.create + // 对外部事务的复用)。 + if err := s.redeemRepo.Use(execCtx, invitation.ID, user.ID); err != nil { + // 并发下唯一的合法失败路径:另一个注册已占用该码 + logger.LegacyPrintf("service.auth", + "[Auth] Rejected registration: invitation code %s already claimed (user_id=%d err=%v)", + invitation.Code, user.ID, err) + return ErrInvitationCodeInvalid + } + return nil + } + + if invitation == nil { + return commitUser(ctx) + } + if s.entClient == nil { + return commitUser(ctx) + } + + tx, err := s.entClient.Tx(ctx) + if err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to start registration transaction: %v", err) + return ErrServiceUnavailable + } + defer func() { _ = tx.Rollback() }() + execCtx := dbent.NewTxContext(ctx, tx) + if err := commitUser(execCtx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to commit registration transaction: %v", err) + return ErrServiceUnavailable + } + return nil +} + func buildEmailSuffixNotAllowedError(whitelist []string) error { if len(whitelist) == 0 { return ErrEmailSuffixNotAllowed diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index 2d78862ef3..9bdca02f2d 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "fmt" "sync" "testing" "time" @@ -13,6 +14,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/authidentity" "github.com/Wei-Shaw/sub2api/ent/enttest" + dbuser "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/repository" @@ -69,7 +71,8 @@ func newAuthServiceForEmailBindWithRefreshCache( ) (*service.AuthService, service.UserRepository, *dbent.Client) { t.Helper() - db, err := sql.Open("sqlite", "file:auth_service_email_bind?mode=memory&cache=shared") + dbName := fmt.Sprintf("file:auth_service_email_bind_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := sql.Open("sqlite", dbName) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -214,6 +217,143 @@ func TestAuthServiceBindEmailIdentity_RejectsExistingEmailOnAnotherUser(t *testi require.Equal(t, 0, countProviderGrantRecords(t, client, sourceUser.ID, "email", "first_bind")) } +func TestAuthServiceBindEmailIdentity_RejectsAliasOfExistingEmailOnAnotherUser(t *testing.T) { + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC().Add(-10 * time.Minute), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil) + + ctx := context.Background() + sourceUser := createEmailBindTestUser( + t, + client, + "source-user"+service.OIDCConnectSyntheticEmailDomain, + "source-user", + "old-hash", + ) + createEmailBindTestUser(t, client, "zck.ioio123@gmail.com", "inbox-owner", "hash") + + err := svc.SendEmailIdentityBindCode(ctx, sourceUser.ID, "zckioio123+new@gmail.com") + require.ErrorIs(t, err, service.ErrEmailExists) + require.Empty(t, cache.setEmails) + + updatedUser, err := svc.BindEmailIdentity( + ctx, + sourceUser.ID, + "zckioio123+new@gmail.com", + "123456", + "new-password", + ) + require.ErrorIs(t, err, service.ErrEmailExists) + require.Nil(t, updatedUser) + + storedUser, err := client.User.Get(ctx, sourceUser.ID) + require.NoError(t, err) + require.Equal(t, "source-user"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email) + require.Equal(t, "old-hash", storedUser.PasswordHash) +} + +func TestAuthServiceBindEmailIdentity_AllowsOnlyOneConcurrentAliasVariant(t *testing.T) { + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil) + + ctx := context.Background() + unique := fmt.Sprintf("%d", time.Now().UnixNano()) + first := createEmailBindTestUser( + t, + client, + "first-"+unique+service.OIDCConnectSyntheticEmailDomain, + "first-"+unique, + "old-hash", + ) + second := createEmailBindTestUser( + t, + client, + "second-"+unique+service.OIDCConnectSyntheticEmailDomain, + "second-"+unique, + "old-hash", + ) + + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := svc.BindEmailIdentity(ctx, first.ID, "inbox-"+unique+"+one@gmail.com", "123456", "new-password") + results <- err + }() + go func() { + <-start + _, err := svc.BindEmailIdentity(ctx, second.ID, "inbox-"+unique+"+two@gmail.com", "123456", "new-password") + results <- err + }() + close(start) + + var successes, conflicts int + for range 2 { + err := <-results + switch { + case err == nil: + successes++ + case errors.Is(err, service.ErrEmailExists): + conflicts++ + default: + t.Fatalf("unexpected bind error: %v", err) + } + } + require.Equal(t, 1, successes) + require.Equal(t, 1, conflicts) + + boundCount, err := client.User.Query(). + Where(dbuser.EmailIn( + "inbox-"+unique+"+one@gmail.com", + "inbox-"+unique+"+two@gmail.com", + )). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, boundCount) +} + +func TestAuthServiceBindEmailIdentity_RejectsNewAliasWhenAnotherUserSharesCurrentUserInbox(t *testing.T) { + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil) + + ctx := context.Background() + hashedPassword, err := svc.HashPassword("current-password") + require.NoError(t, err) + currentUser := createEmailBindTestUser(t, client, "inbox+own@gmail.com", "current", hashedPassword) + createEmailBindTestUser(t, client, "inbox+legacy@gmail.com", "legacy", "hash") + + updatedUser, err := svc.BindEmailIdentity( + ctx, + currentUser.ID, + "inbox+new@gmail.com", + "123456", + "current-password", + ) + require.ErrorIs(t, err, service.ErrEmailExists) + require.Nil(t, updatedUser) + + storedUser, err := client.User.Get(ctx, currentUser.ID) + require.NoError(t, err) + require.Equal(t, "inbox+own@gmail.com", storedUser.Email) +} + func TestAuthServiceBindEmailIdentity_RollsBackWhenFirstBindDefaultsFail(t *testing.T) { assigner := &flakyEmailBindDefaultSubAssignerStub{err: errors.New("temporary assign failure")} cache := &emailBindCacheStub{ diff --git a/backend/internal/service/auth_service_invitation_race_test.go b/backend/internal/service/auth_service_invitation_race_test.go new file mode 100644 index 0000000000..e1e7c82aef --- /dev/null +++ b/backend/internal/service/auth_service_invitation_race_test.go @@ -0,0 +1,236 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// raceSafeUserRepo 是仅覆盖注册路径的并发安全用户仓储桩。 +// 未用到的方法走嵌入接口(调用即 panic,注册路径不会触发)。 +type raceSafeUserRepo struct { + UserRepository + + mu sync.Mutex + nextID int64 + byEmail map[string]*User + byID map[int64]*User +} + +func newRaceSafeUserRepo() *raceSafeUserRepo { + return &raceSafeUserRepo{nextID: 1, byEmail: map[string]*User{}, byID: map[int64]*User{}} +} + +func (s *raceSafeUserRepo) ExistsByEmail(_ context.Context, email string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.byEmail[email] + return ok, nil +} + +func (s *raceSafeUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + return s.ExistsByEmail(ctx, email) +} + +func (s *raceSafeUserRepo) CreateWithEmailAliasGuard(_ context.Context, user *User) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byEmail[user.Email]; ok { + return ErrEmailExists + } + user.ID = s.nextID + s.nextID++ + clone := *user + s.byEmail[user.Email] = &clone + s.byID[user.ID] = &clone + return nil +} + +func (s *raceSafeUserRepo) GetByEmail(_ context.Context, email string) (*User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byEmail[email] + if !ok { + return nil, ErrUserNotFound + } + clone := *u + return &clone, nil +} + +func (s *raceSafeUserRepo) GetByID(_ context.Context, id int64) (*User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[id] + if !ok { + return nil, ErrUserNotFound + } + clone := *u + return &clone, nil +} + +func (s *raceSafeUserRepo) Update(context.Context, *User, UserUpdateFields) error { + return nil +} + +// raceSafeRedeemRepo 是并发安全的兑换码仓储桩:Use 以互斥锁 + 状态条件 +// 模拟数据库的条件更新(WHERE status='unused'),语义与线上实现一致。 +type raceSafeRedeemRepo struct { + RedeemCodeRepository + + mu sync.Mutex + codes map[string]*RedeemCode +} + +func (s *raceSafeRedeemRepo) GetByCode(_ context.Context, code string) (*RedeemCode, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.codes[code] + if !ok { + return nil, ErrRedeemCodeNotFound + } + clone := *c + return &clone, nil +} + +func (s *raceSafeRedeemRepo) Use(_ context.Context, id, userID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, c := range s.codes { + if c.ID != id { + continue + } + if c.Status != StatusUnused { + return ErrRedeemCodeUsed + } + now := time.Now().UTC() + c.Status = StatusUsed + c.UsedBy = &userID + c.UsedAt = &now + return nil + } + return ErrRedeemCodeNotFound +} + +// TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency 回归测试: +// 同一邀请码并发注册必须恰好成功 1 次,其余请求以 INVITATION_CODE_INVALID 拒绝。 +// +// 修复前:邀请码“检查(CanUse) 与 标记已用(Use)”分离且不在同一事务,Use 失败被吞, +// 并发请求全部注册成功(一个邀请码可创建任意数量账号)。此测试在该实现下必然失败。 +// 修复后:用户创建与邀请码占用在同一事务内原子完成(或退化路径下由 Use 条件更新 +// 兜底),并发下仅最先占码的注册成功。 +func TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency(t *testing.T) { + const code = "INV-RACE-001" + userRepo := newRaceSafeUserRepo() + redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{ + code: {ID: 1, Code: code, Type: RedeemTypeInvitation, Status: StatusUnused}, + }} + settings := map[string]string{ + "registration_enabled": "true", + "invitation_code_enabled": "true", + } + svc := newOAuthEmailFlowAuthService( + userRepo, + redeemRepo, + &refreshTokenCacheStub{}, + settings, + nil, // emailCache:注册不要求邮箱验证,保持关闭 + &userPlatformQuotaRepoStub{}, + ) + + const n = 8 + ctx := context.Background() + start := make(chan struct{}) + results := make(chan error, n) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + email := fmt.Sprintf("race-%d@example.com", i) + _, _, err := svc.RegisterWithVerification(ctx, email, "Password123!", "", "", code, "") + results <- err + }(i) + } + close(start) + wg.Wait() + close(results) + + successes := 0 + rejected := 0 + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrInvitationCodeInvalid): + rejected++ + default: + t.Fatalf("unexpected registration error: %v", err) + } + } + require.Equal(t, 1, successes, "同一邀请码并发注册必须恰好成功 1 次") + require.Equal(t, n-1, rejected, "其余并发请求必须以 INVITATION_CODE_INVALID 拒绝") + + claimed, err := redeemRepo.GetByCode(ctx, code) + require.NoError(t, err) + require.Equal(t, StatusUsed, claimed.Status, "邀请码最终必须处于 used 状态") + require.NotNil(t, claimed.UsedBy, "used_by 必须记录实际注册用户") +} + +// TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed 顺序路径回归: +// 已使用过的邀请码再次注册(即便换邮箱)必须被拒绝。 +func TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed(t *testing.T) { + const code = "INV-RACE-002" + userRepo := newRaceSafeUserRepo() + redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{ + code: {ID: 2, Code: code, Type: RedeemTypeInvitation, Status: StatusUsed}, + }} + settings := map[string]string{ + "registration_enabled": "true", + "invitation_code_enabled": "true", + } + svc := newOAuthEmailFlowAuthService( + userRepo, + redeemRepo, + &refreshTokenCacheStub{}, + settings, + nil, + &userPlatformQuotaRepoStub{}, + ) + + _, _, err := svc.RegisterWithVerification(context.Background(), "later@example.com", "Password123!", "", "", code, "") + require.ErrorIs(t, err, ErrInvitationCodeInvalid) +} + +// TestAuthService_Register_InvitationCodeMissingWhenEnabled 门控回归: +// 邀请码开启时,不带邀请码的注册必须被拒绝(不产生用户)。 +func TestAuthService_Register_InvitationCodeMissingWhenEnabled(t *testing.T) { + userRepo := newRaceSafeUserRepo() + redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{}} + settings := map[string]string{ + "registration_enabled": "true", + "invitation_code_enabled": "true", + } + svc := newOAuthEmailFlowAuthService( + userRepo, + redeemRepo, + &refreshTokenCacheStub{}, + settings, + nil, + &userPlatformQuotaRepoStub{}, + ) + + _, _, err := svc.RegisterWithVerification(context.Background(), "no-invite@example.com", "Password123!", "", "", "", "") + require.ErrorIs(t, err, ErrInvitationCodeRequired) + + ok, err := userRepo.ExistsByEmail(context.Background(), "no-invite@example.com") + require.NoError(t, err) + require.False(t, ok, "被拒绝的注册不应产生用户") +} diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index 04d7efaeb9..c29fdac0c3 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -5,6 +5,7 @@ package service import ( "context" "errors" + "sync" "testing" "time" @@ -14,6 +15,7 @@ import ( ) type settingRepoStub struct { + mu sync.Mutex values map[string]string err error getValueCalls int @@ -25,6 +27,8 @@ func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) } func (s *settingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() s.getValueCalls++ if s.err != nil { return "", s.err @@ -40,6 +44,8 @@ func (s *settingRepoStub) Set(ctx context.Context, key, value string) error { } func (s *settingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() s.getMultipleCalls++ if s.err != nil { return nil, s.err diff --git a/backend/internal/service/bedrock_stream.go b/backend/internal/service/bedrock_stream.go index 98196d27ec..9ce6b87e24 100644 --- a/backend/internal/service/bedrock_stream.go +++ b/backend/internal/service/bedrock_stream.go @@ -139,7 +139,7 @@ func (s *GatewayService) handleBedrockStreamingResponse( sseData = transformBedrockInvocationMetrics(sseData) // 解析 SSE 事件数据提取 usage - s.parseSSEUsagePassthrough(string(sseData), usage) + parseSSEUsagePassthrough(string(sseData), usage) // 确定 SSE event type eventType := gjson.GetBytes(sseData, "type").String() diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go new file mode 100644 index 0000000000..05f2a181b1 --- /dev/null +++ b/backend/internal/service/billing_context_schedule.go @@ -0,0 +1,470 @@ +package service + +import ( + "context" + "errors" + "math" + "sort" + "strconv" + "time" +) + +// ContextPricingBasis 阶梯的计价基准。 +type ContextPricingBasis string + +const ( + // ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。 + ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" + // ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。 + ContextPricingBasisMarginal ContextPricingBasis = "marginal" +) + +// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。 +// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。 +type ContextPricingTier struct { + MinTokens int + MaxTokens *int + Label string + Input *float64 + Output *float64 + CacheWrite *float64 + CacheRead *float64 +} + +// TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。 +type TimePricingPeriod struct { + StartTime string + EndTime string + Multiplier float64 +} + +// TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。 +// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。 +type TimePricingSchedule struct { + Timezone string + WeekdaysOnly bool + Periods []TimePricingPeriod +} + +// ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。 +// 单价由真实计费函数探针得出,与扣费同源;单档表示无阶梯。 +// Tiers 为标准时段单价;TimePricing 非 nil 时,落在时段内的请求整单再乘对应倍率。 +type ContextPricingSchedule struct { + Basis ContextPricingBasis + Tiers []ContextPricingTier + TimePricing *TimePricingSchedule +} + +// ContextPricingScheduleInput 阶梯表查询输入。 +type ContextPricingScheduleInput struct { + Model string + // Group 为 nil 表示查官方参考价:无分组、无渠道定价,也不套用平台旧规则。 + Group *Group + // Platform 为请求的具体平台(composite 分组传模型所属平台), + // 决定渠道定价查找与平台旧规则的适用。 + Platform string +} + +var errContextPricingResolverRequired = errors.New("context pricing schedule: resolver is required") + +// 探针步长:相邻两个探针点相差 contextProbeDelta 个 token,单价 = Δcost / Δtoken。 +const contextProbeDelta = 1000 + +// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。 +// +// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价, +// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。 +// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值), +// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等 +// 规则变更无需同步到这里;相邻同价段会合并。 +// +// 非 token 计费模式返回 (nil, nil);模型无任何定价来源时返回 ErrModelPricingUnavailable。 +func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, resolver *ModelPricingResolver, in ContextPricingScheduleInput) (*ContextPricingSchedule, error) { + if s == nil || resolver == nil { + return nil, errContextPricingResolverRequired + } + if ctx == nil { + ctx = context.Background() + } + if in.Platform != "" { + ctx = WithResolvedTargetPlatform(ctx, in.Platform) + } + + pricingInput := PricingInput{Model: in.Model, Group: in.Group} + if in.Group != nil { + gid := in.Group.ID + pricingInput.GroupID = &gid + } + resolved := resolver.Resolve(ctx, pricingInput) + if resolved == nil { + return nil, ErrModelPricingUnavailable + } + if resolved.Mode != "" && resolved.Mode != BillingModeToken { + return nil, nil + } + + var legacy *LegacyLongContextRule + if in.Group != nil { + legacy = s.LegacyLongContextRule(in.Platform) + } + if !legacyLongContextApplies(resolved, in.Group, legacy) { + legacy = nil + } + + req := TokenCostRequest{ + Ctx: ctx, + Model: in.Model, + Group: in.Group, + RateMultiplier: 1, + Resolver: resolver, + Resolved: resolved, + LegacyLongContext: legacy, + } + probe := func(tokens UsageTokens) (*CostBreakdown, error) { + r := req + r.Tokens = tokens + return s.CalculateTokenCostForRequest(r) + } + + plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy) + segments := buildContextSegments(plan.bounds) + + tiers := make([]ContextPricingTier, 0, len(segments)) + for _, seg := range segments { + tier, err := probeContextTier(seg, resolved, probe) + if err != nil { + return nil, err + } + tiers = append(tiers, tier) + } + tiers = mergeEqualContextTiers(tiers) + applyContextTierLabels(tiers, plan) + + basis := ContextPricingBasisWholeRequest + if legacy != nil { + basis = ContextPricingBasisMarginal + } + return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil +} + +// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。 +// 时段来自解析到的渠道定价配置,每个时段的倍率用计费自己的 resolvedChannelTimeMultiplier +// 在时段内取值:定价来源不是渠道(分组价卡覆盖)、配置非法等情况下计费按 1 计, +// 这里也就自然得到"无分时"。倍率为 1 的时段不列出。 +func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule { + if resolved == nil || resolved.channelPricing == nil || resolved.channelPricing.TimePricing == nil { + return nil + } + cfg := resolved.channelPricing.TimePricing + location, err := loadChannelTimePricingLocation(cfg.Timezone) + if err != nil { + return nil + } + type probedPeriod struct { + start int + period TimePricingPeriod + } + probed := make([]probedPeriod, 0, len(cfg.Periods)) + for _, period := range cfg.Periods { + start, err := parseChannelTime(period.StartTime, false) + if err != nil { + continue + } + // 时段按每日循环,取该时段开始后 1 秒作为探针时刻。 + // 锚点日必须是工作日(2026-01-05 为周一):weekdays_only 配置在周末恒为 1, + // 锚点落在周末会把时段整组剔除。 + at := time.Date(2026, time.January, 5, 0, 0, start+1, 0, location) + multiplier := resolvedChannelTimeMultiplier(resolved, at) + if multiplier == 1 { + continue + } + probed = append(probed, probedPeriod{start: start, period: TimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: multiplier, + }}) + } + if len(probed) == 0 { + return nil + } + sort.SliceStable(probed, func(i, j int) bool { return probed[i].start < probed[j].start }) + out := &TimePricingSchedule{ + Timezone: cfg.Timezone, + WeekdaysOnly: cfg.WeekdaysOnly, + Periods: make([]TimePricingPeriod, 0, len(probed)), + } + for _, p := range probed { + out.Periods = append(out.Periods, p.period) + } + return out +} + +// contextBreakpointPlan 描述断点来源。 +type contextBreakpointPlan struct { + bounds []int + // thresholdBound 为目录阶梯/旧规则的断点值((0,b] / (b,∞)),0 表示无。 + thresholdBound int + // thresholdInclusive 为真表示达到阈值即进入高档(断点 = 阈值-1)。 + thresholdInclusive bool + threshold int +} + +// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。 +func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan { + plan := contextBreakpointPlan{} + if legacy != nil { + plan.bounds = []int{legacy.Threshold} + plan.thresholdBound = legacy.Threshold + plan.threshold = legacy.Threshold + return plan + } + if !resolved.longContextPricingEnabled { + return plan + } + if len(resolved.Intervals) > 0 { + // 区间边界即断点;空洞段(含末档上限之外)由计费回落 base,探针会自然得到基础价。 + set := make(map[int]struct{}, len(resolved.Intervals)*2) + for i := range resolved.Intervals { + iv := &resolved.Intervals[i] + if iv.MinTokens > 0 { + set[iv.MinTokens] = struct{}{} + } + if iv.MaxTokens != nil { + set[*iv.MaxTokens] = struct{}{} + } + } + for b := range set { + plan.bounds = append(plan.bounds, b) + } + sort.Ints(plan.bounds) + return plan + } + pricing := resolver.GetIntervalPricing(resolved, 1) + if pricing == nil { + return plan + } + pricing = s.applyModelSpecificPricingPolicy(model, pricing) + if pricing.LongContextInputThreshold <= 0 { + return plan + } + bound := pricing.LongContextInputThreshold + if pricing.LongContextThresholdInclusive { + bound-- + } + if bound <= 0 { + return plan + } + plan.bounds = []int{bound} + plan.thresholdBound = bound + plan.threshold = pricing.LongContextInputThreshold + plan.thresholdInclusive = pricing.LongContextThresholdInclusive + return plan +} + +// contextSegment 探针用的 (min, max] 段;max 为 nil 表示无上限。 +type contextSegment struct { + min int + max *int +} + +// buildContextSegments 把升序断点切成 (0,b1], (b1,b2], …, (bn,∞);无断点时为单个开区间。 +func buildContextSegments(bounds []int) []contextSegment { + segments := make([]contextSegment, 0, len(bounds)+1) + prev := 0 + for _, b := range bounds { + if b <= prev { + continue + } + upper := b + segments = append(segments, contextSegment{min: prev, max: &upper}) + prev = b + } + segments = append(segments, contextSegment{min: prev}) + return segments +} + +// probeContextTier 在段内两点探针,单价 = ΔActualCost / Δtoken(倍率固定为 1)。 +// 每次只喂一种 token,ActualCost 即该项费用;旧边际规则的加倍只体现在 ActualCost +// 而不在分项费用里,因此统一读 ActualCost。整单阶梯与边际规则在同一段内都是 +// 线性函数,差商同时适用,无需区分规则类型。 +func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func(UsageTokens) (*CostBreakdown, error)) (ContextPricingTier, error) { + tier := ContextPricingTier{MinTokens: seg.min, MaxTokens: seg.max} + c := seg.min + 1 + delta := contextProbeDelta + if seg.max != nil { + if width := *seg.max - seg.min; width-1 < delta { + delta = width - 1 + } + } + + var err error + tier.Input, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } + tier.CacheRead, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheReadTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } + tier.CacheWrite, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } + // 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。 + tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe) + if err != nil { + return tier, err + } + + explicit := explicitContextPricingFields(resolved, c) + tier.Input = contextPricePtr(tier.Input, explicit.input) + tier.Output = contextPricePtr(tier.Output, explicit.output) + tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite) + tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead) + return tier, nil +} + +// probeComponentPrice 返回 [from, from+delta] 上 ActualCost 的差商;delta 为 0(退化段)时退回平均单价。 +func probeComponentPrice(tokensAt func(int) UsageTokens, from, delta int, probe func(UsageTokens) (*CostBreakdown, error)) (*float64, error) { + if delta <= 0 { + n := from + if n <= 0 { + n = 1 + } + cost, err := probe(tokensAt(n)) + if err != nil { + return nil, err + } + v := roundContextPrice(cost.ActualCost / float64(n)) + return &v, nil + } + lo, err := probe(tokensAt(from)) + if err != nil { + return nil, err + } + hi, err := probe(tokensAt(from + delta)) + if err != nil { + return nil, err + } + v := roundContextPrice((hi.ActualCost - lo.ActualCost) / float64(delta)) + return &v, nil +} + +// roundContextPrice 去掉差商带来的浮点噪声(保留 12 位有效数字)。 +func roundContextPrice(v float64) float64 { + if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 12, 64), 64) + if err != nil { + return v + } + return r +} + +type explicitContextFields struct { + input, output, cacheWrite, cacheRead bool +} + +// explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。 +// 显式配置为 0 时计费按 $0 收,展示应为 $0 而非“无价”。 +func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int) explicitContextFields { + var out explicitContextFields + if resolved == nil || resolved.channelPricing == nil { + return out + } + cp := resolved.channelPricing + out.input = cp.InputPrice != nil + out.output = cp.OutputPrice != nil + out.cacheWrite = cp.CacheWritePrice != nil + out.cacheRead = cp.CacheReadPrice != nil + if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil { + out.input = out.input || iv.InputPrice != nil + out.output = out.output || iv.OutputPrice != nil + out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil + out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil + } + return out +} + +func contextPricePtr(v *float64, explicit bool) *float64 { + if v == nil { + return nil + } + if *v == 0 && !explicit { + return nil + } + return v +} + +// mergeEqualContextTiers 合并相邻且四项单价相同的段(倍率 ≤1 的目录、关闭阶梯等场景塌成单档)。 +func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier { + if len(tiers) < 2 { + return tiers + } + merged := make([]ContextPricingTier, 0, len(tiers)) + for _, t := range tiers { + if n := len(merged); n > 0 && sameContextPrices(merged[n-1], t) { + merged[n-1].MaxTokens = t.MaxTokens + continue + } + merged = append(merged, t) + } + return merged +} + +func sameContextPrices(a, b ContextPricingTier) bool { + return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) && + samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheRead, b.CacheRead) +} + +func samePricePtr(a, b *float64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + if *a == *b { + return true + } + scale := math.Max(math.Abs(*a), math.Abs(*b)) + return math.Abs(*a-*b) <= scale*1e-9 +} + +// applyContextTierLabels 给多档阶梯打统一形态的标签:有上限的档为「≤上限」, +// 末档为「>下限」;档位按上下文升序,因此相邻的 ≤100K / ≤200K 即表示 (100K,200K]。 +// 目录阶梯/旧规则在"达到阈值即进高档"时改用 < / ≥ 表达阈值本身。 +// 渠道区间上的 tier_label 不用于 token 档位(token 模式的管理表单不暴露该字段)。 +func applyContextTierLabels(tiers []ContextPricingTier, plan contextBreakpointPlan) { + if len(tiers) < 2 { + return + } + for i := range tiers { + t := &tiers[i] + switch { + case plan.thresholdInclusive && t.MaxTokens != nil && *t.MaxTokens == plan.thresholdBound: + t.Label = "<" + formatContextTokenCount(plan.threshold) + case plan.thresholdInclusive && t.MinTokens == plan.thresholdBound: + t.Label = "≥" + formatContextTokenCount(plan.threshold) + case t.MaxTokens != nil: + t.Label = "≤" + formatContextTokenCount(*t.MaxTokens) + default: + t.Label = ">" + formatContextTokenCount(t.MinTokens) + } + } +} + +// formatContextTokenCount 把 token 数格式化为 272K / 1M 等短标签。 +func formatContextTokenCount(n int) string { + switch { + case n >= 1_000_000 && n%1_000_000 == 0: + return strconv.Itoa(n/1_000_000) + "M" + case n >= 1_000_000: + return trimFloatLabel(float64(n)/1_000_000) + "M" + case n >= 1_000: + return trimFloatLabel(float64(n)/1_000) + "K" + } + return strconv.Itoa(n) +} + +func trimFloatLabel(v float64) string { + return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64) +} diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go new file mode 100644 index 0000000000..b13606da9f --- /dev/null +++ b/backend/internal/service/billing_context_schedule_test.go @@ -0,0 +1,620 @@ +//go:build unit + +package service + +import ( + "context" + "math" + "math/rand" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// scheduleScenario 阶梯表测试场景:同一份场景既做断言也做与计费函数的对账。 +type scheduleScenario struct { + name string + model string + platform string + groupPlatform string + group *Group + channel []ChannelModelPricing + catalog *PricingService + wantErr bool + wantNil bool + wantBasis ContextPricingBasis + check func(t *testing.T, s *ContextPricingSchedule) +} + +func enabledGroup(platform string) *Group { + return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: true} +} + +func disabledGroup(platform string) *Group { + return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: false} +} + +func sonnetChannel(iv ...PricingInterval) []ChannelModelPricing { + return []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(2e-6), Intervals: iv, + }} +} + +func requireTier(t *testing.T, tier ContextPricingTier, min int, max *int, label string, input, output, cacheWrite, cacheRead *float64) { + t.Helper() + require.Equal(t, min, tier.MinTokens, "min") + if max == nil { + require.Nil(t, tier.MaxTokens, "max should be unbounded") + } else { + require.NotNil(t, tier.MaxTokens, "max") + require.Equal(t, *max, *tier.MaxTokens, "max") + } + require.Equal(t, label, tier.Label, "label") + requirePrice(t, input, tier.Input, "input") + requirePrice(t, output, tier.Output, "output") + requirePrice(t, cacheWrite, tier.CacheWrite, "cache_write") + requirePrice(t, cacheRead, tier.CacheRead, "cache_read") +} + +func requirePrice(t *testing.T, want, got *float64, field string) { + t.Helper() + if want == nil { + require.Nil(t, got, field) + return + } + require.NotNil(t, got, field) + require.InDelta(t, *want, *got, 1e-15, field) +} + +func scheduleScenarios() []scheduleScenario { + p := testPtrFloat64 + return []scheduleScenario{ + { + name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) + requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6)) + }, + }, + { + name: "Grok 达到阈值即进高档", model: "grok-4.5", platform: PlatformGrok, groupPlatform: PlatformGrok, + group: enabledGroup(PlatformGrok), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(199999), "<200K", p(2e-6), p(6e-6), nil, p(0.3e-6)) + requireTier(t, s.Tiers[1], 199999, nil, "≥200K", p(4e-6), p(12e-6), nil, p(0.6e-6)) + }, + }, + { + name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) + }, + }, + { + name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI, + group: nil, wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6)) + }, + }, + { + name: "渠道倍率区间按渠道平价覆盖后的 base 折算(区间自定义标签不用于档位)", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, TierLabel: "long", InputMultiplier: p(2), OutputMultiplier: p(1.5), CacheWriteMultiplier: p(2), CacheReadMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(22.5e-6), p(7.5e-6), p(0.6e-6)) + }, + }, + { + name: "区间显式价优先于倍率", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, InputPrice: p(9e-6), InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requirePrice(t, p(9e-6), s.Tiers[1].Input, "input") + }, + }, + { + name: "区间空洞按 base 补档且同价段合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(0.5)}, + PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 3) + requireTier(t, s.Tiers[0], 0, intPtr(100000), "≤100K", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[1], 100000, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[2], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "首档同价空洞被合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "末档有上限时尾段回落 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, MaxTokens: intPtr(1000000), InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 3) + requireTier(t, s.Tiers[1], 200000, intPtr(1000000), "≤1M", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[2], 1000000, nil, ">1M", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "分组关闭时渠道区间折叠到 1-token 档", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(0.5)}, + PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "分组关闭且首档不从 0 起时平价为 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requirePrice(t, p(2e-6), s.Tiers[0].Input, "input") + }, + }, + { + name: "分组 token 价卡整张替换渠道定价并剥区间", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), + Intervals: []PricingInterval{{MinTokens: 200000, InputMultiplier: p(5)}}, + }}}, + channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(3)}), + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "分组价卡之上叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), + }}}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requirePrice(t, p(1e-6), s.Tiers[0].Input, "input") + requirePrice(t, p(2e-6), s.Tiers[1].Input, "input") + requirePrice(t, p(22.5e-6), s.Tiers[1].Output, "output") + }, + }, + { + name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6)) + }, + }, + { + name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + }, + }, + { + name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), + channel: []ChannelModelPricing{{ + Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6), + }}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requirePrice(t, p(3e-6), s.Tiers[0].Input, "input") + }, + }, + { + name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini, + group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + }, + }, + { + name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite, + group: enabledGroup(PlatformComposite), + channel: []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), + }}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requirePrice(t, p(1e-6), s.Tiers[0].Input, "input") + requirePrice(t, p(2e-6), s.Tiers[1].Input, "input") + }, + }, + { + name: "渠道显式 cache_write=0 保留为 0", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), + channel: []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: p(2e-6), CacheWritePrice: p(0), + }}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + require.NotNil(t, s.Tiers[0].CacheWrite) + require.Zero(t, *s.Tiers[0].CacheWrite) + }, + }, + { + name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), + catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ + "gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6}, + }), + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(5e-6), p(30e-6), p(6.25e-6), p(0.5e-6)) + requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(10e-6), p(45e-6), p(12.5e-6), p(1e-6)) + }, + }, + { + name: "图片模式返回 nil", model: "gpt-image-2", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), + channel: []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage, PerRequestPrice: p(0.04), + }}, + wantNil: true, + }, + { + name: "无任何定价来源报错", model: "unknown-model-xyz", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), wantErr: true, + }, + } +} + +func newScheduleTestEnv(t *testing.T, sc scheduleScenario) (*BillingService, *ModelPricingResolver) { + t.Helper() + return newTokenCostTestEnv(t, sc.groupPlatform, sc.channel, sc.catalog) +} + +func TestResolveContextPricingSchedule_Scenarios(t *testing.T) { + for _, sc := range scheduleScenarios() { + t.Run(sc.name, func(t *testing.T) { + bs, resolver := newScheduleTestEnv(t, sc) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: sc.model, Group: sc.group, Platform: sc.platform, + }) + if sc.wantErr { + require.Error(t, err) + require.Nil(t, sched) + return + } + require.NoError(t, err) + if sc.wantNil { + require.Nil(t, sched) + return + } + require.NotNil(t, sched) + require.Equal(t, sc.wantBasis, sched.Basis) + require.NotEmpty(t, sched.Tiers) + require.Zero(t, sched.Tiers[0].MinTokens, "首档从 0 起") + for i := 1; i < len(sched.Tiers); i++ { + require.NotNil(t, sched.Tiers[i-1].MaxTokens) + require.Equal(t, *sched.Tiers[i-1].MaxTokens, sched.Tiers[i].MinTokens, "档位连续") + require.Less(t, sched.Tiers[i-1].MinTokens, sched.Tiers[i].MinTokens, "档位按上下文升序") + } + if len(sched.Tiers) > 1 { + for i, tier := range sched.Tiers { + require.NotEmpty(t, tier.Label, "多档时每档都有标签 #%d", i) + } + require.Nil(t, sched.Tiers[len(sched.Tiers)-1].MaxTokens, "末档无上限") + } + if sc.check != nil { + sc.check(t, sched) + } + }) + } +} + +func TestResolveContextPricingSchedule_NilResolver(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), nil, ContextPricingScheduleInput{Model: "gpt-5.4"}) + require.Error(t, err) + require.Nil(t, sched) +} + +// --- 对账:阶梯表推算的费用必须等于真实计费函数 --- + +type tokenKind int + +const ( + kindInput tokenKind = iota + kindOutput + kindCacheRead + kindCacheWrite +) + +func tierPrice(tier ContextPricingTier, kind tokenKind) float64 { + var p *float64 + switch kind { + case kindInput: + p = tier.Input + case kindOutput: + p = tier.Output + case kindCacheRead: + p = tier.CacheRead + case kindCacheWrite: + p = tier.CacheWrite + } + if p == nil { + return 0 + } + return *p +} + +func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier { + for _, tier := range tiers { + if contextTokens > tier.MinTokens && (tier.MaxTokens == nil || contextTokens <= *tier.MaxTokens) { + return tier + } + } + return tiers[len(tiers)-1] +} + +// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用: +// 整单基准取所在档单价 × 全量;边际基准逐段累加。 +func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 { + if s.Basis == ContextPricingBasisMarginal { + total := 0.0 + for _, tier := range s.Tiers { + if contextTokens <= tier.MinTokens { + break + } + upper := contextTokens + if tier.MaxTokens != nil && *tier.MaxTokens < upper { + upper = *tier.MaxTokens + } + total += float64(upper-tier.MinTokens) * tierPrice(tier, kind) + } + return total + } + return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind) +} + +func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) { + const outputProbe = 1000 + rng := rand.New(rand.NewSource(20260823)) + for _, sc := range scheduleScenarios() { + if sc.wantErr || sc.wantNil { + continue + } + t.Run(sc.name, func(t *testing.T) { + bs, resolver := newScheduleTestEnv(t, sc) + ctx := context.Background() + if sc.platform != "" { + ctx = WithResolvedTargetPlatform(ctx, sc.platform) + } + sched, err := bs.ResolveContextPricingSchedule(ctx, resolver, ContextPricingScheduleInput{ + Model: sc.model, Group: sc.group, Platform: sc.platform, + }) + require.NoError(t, err) + require.NotNil(t, sched) + + // 消费方视角重建同一份计费请求(与网关一致)。 + pricingInput := PricingInput{Model: sc.model, Group: sc.group} + if sc.group != nil { + gid := sc.group.ID + pricingInput.GroupID = &gid + } + resolved := resolver.Resolve(ctx, pricingInput) + var legacy *LegacyLongContextRule + if sc.group != nil { + legacy = bs.LegacyLongContextRule(sc.platform) + } + if !legacyLongContextApplies(resolved, sc.group, legacy) { + legacy = nil + } + cost := func(tokens UsageTokens) float64 { + bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy, + }) + require.NoError(t, err) + return bd.ActualCost + } + + probes := []int{1, 2, 999, 1000, 1001, 10_000_000} + for _, tier := range sched.Tiers { + for _, b := range []*int{tier.MaxTokens} { + if b == nil { + continue + } + probes = append(probes, *b-1, *b, *b+1) + } + } + for i := 0; i < 8; i++ { + probes = append(probes, 1+rng.Intn(3_000_000)) + } + + for _, c := range probes { + if c <= 0 { + continue + } + assertCostClose(t, expectedCostFromSchedule(sched, kindInput, c), cost(UsageTokens{InputTokens: c}), "input@%d", c) + assertCostClose(t, expectedCostFromSchedule(sched, kindCacheRead, c), cost(UsageTokens{CacheReadTokens: c}), "cache_read@%d", c) + assertCostClose(t, expectedCostFromSchedule(sched, kindCacheWrite, c), cost(UsageTokens{CacheCreationTokens: c}), "cache_write@%d", c) + wantOutput := float64(outputProbe) * tierPrice(tierAt(sched.Tiers, c), kindOutput) + gotOutput := cost(UsageTokens{InputTokens: c, OutputTokens: outputProbe}) - cost(UsageTokens{InputTokens: c}) + assertCostClose(t, wantOutput, gotOutput, "output@%d", c) + } + }) + } +} + +func assertCostClose(t *testing.T, want, got float64, format string, args ...any) { + t.Helper() + tolerance := 1e-12 + 1e-9*math.Abs(want) + require.InDeltaf(t, want, got, tolerance, format, args...) +} + +func sonnetChannelWithTimePricing(tp *ChannelTimePricing) []ChannelModelPricing { + return []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(2e-6), TimePricing: tp, + }} +} + +func TestResolveContextPricingSchedule_TimePricing(t *testing.T) { + valid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "12:00", EndTime: "13:00", Multiplier: 1}, + }} + + t.Run("渠道分时按开始时间升序列出且跳过倍率 1 的时段", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.NotNil(t, sched.TimePricing) + require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone) + require.False(t, sched.TimePricing.WeekdaysOnly) + require.Equal(t, []TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, + }, sched.TimePricing.Periods) + // 阶梯表单价是标准时段价,不含分时倍率 + requirePrice(t, testPtrFloat64(2e-6), sched.Tiers[0].Input, "input") + + // 对账:时段内的真实计费 = 标准单价 × token × 倍率 + group := enabledGroup(PlatformAnthropic) + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group}) + loc, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + for _, tc := range []struct { + at time.Time + want float64 + }{ + {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 0.5}, + {time.Date(2026, 8, 23, 12, 30, 0, 0, loc), 1}, + {time.Date(2026, 8, 23, 21, 59, 59, 0, loc), 1.2}, + {time.Date(2026, 8, 23, 22, 0, 0, 0, loc), 1}, + } { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000}, + RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at) + } + }) + + t.Run("仅工作日配置透传标注且时段仍列出", func(t *testing.T) { + weekdays := &ChannelTimePricing{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }} + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(weekdays), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.NotNil(t, sched.TimePricing, "探针锚点必须落在工作日,否则仅工作日配置的时段会被整组剔除") + require.True(t, sched.TimePricing.WeekdaysOnly) + require.Equal(t, []TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }, sched.TimePricing.Periods) + + // 对账:工作日时段内乘倍率,周末同一时段按标准价 + group := enabledGroup(PlatformAnthropic) + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group}) + loc, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + for _, tc := range []struct { + at time.Time + want float64 + }{ + {time.Date(2026, 8, 24, 3, 0, 0, 0, loc), 0.5}, // 周一 + {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 1}, // 周日 + } { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000}, + RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at) + } + }) + + t.Run("配置非法时计费按 1 计,阶梯表不列分时", func(t *testing.T) { + invalid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "08:00", EndTime: "09:00", Multiplier: 0.8}, // 与上一段重叠 + }} + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(invalid), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) + + t.Run("分组价卡覆盖后渠道分时不再生效", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil) + group := &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6), + }}} + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) + + t.Run("无分时配置为 nil", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(nil), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 0356f698a0..a2727af7a4 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "math" "strings" "sync" "time" @@ -91,25 +92,27 @@ type BillingCache interface { // ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致) type ModelPricing struct { - InputPricePerToken float64 // 每token输入价格 (USD) - InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) - ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken - OutputPricePerToken float64 // 每token输出价格 (USD) - OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) - CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) - CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) - CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) - CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) - CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) - CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) - CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) - SupportsCacheBreakdown bool // 是否支持详细的缓存分类 - LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 - LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 - LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 - LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 - ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) - ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) + InputPricePerToken float64 // 每token输入价格 (USD) + InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) + ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken + OutputPricePerToken float64 // 每token输出价格 (USD) + OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) + CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) + CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) + CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) + CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) + CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) + FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 + FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) + CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) + SupportsCacheBreakdown bool // 是否支持详细的缓存分类 + LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 + LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 + LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 + ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) + ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } const ( @@ -123,7 +126,14 @@ func normalizeBillingServiceTier(serviceTier string) string { } func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bool { - if pricing == nil || normalizeBillingServiceTier(serviceTier) != "priority" { + if pricing == nil { + return false + } + tier := normalizeBillingServiceTier(serviceTier) + if tier != "priority" && tier != "fast" { + return false + } + if pricing.FastMultiplier != nil { return false } return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 || @@ -132,7 +142,7 @@ func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bo func serviceTierCostMultiplier(serviceTier string) float64 { switch normalizeBillingServiceTier(serviceTier) { - case "priority": + case "priority", "fast": return 2.0 case "flex": return 0.5 @@ -141,6 +151,34 @@ func serviceTierCostMultiplier(serviceTier string) float64 { } } +func configuredServiceTierMultiplier(serviceTier string, pricing *ModelPricing) float64 { + if pricing != nil { + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + if pricing.FastMultiplier != nil { + return *pricing.FastMultiplier + } + case "flex": + if pricing.FlexMultiplier != nil { + return *pricing.FlexMultiplier + } + } + } + return serviceTierCostMultiplier(serviceTier) +} + +func pricingWithPriorityMultiplier(base *ModelPricing, multiplier float64) *ModelPricing { + if base == nil { + return nil + } + cloned := *base + cloned.InputPricePerTokenPriority = cloned.InputPricePerToken * multiplier + cloned.OutputPricePerTokenPriority = cloned.OutputPricePerToken * multiplier + cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * multiplier + cloned.CacheReadPricePerTokenPriority = cloned.CacheReadPricePerToken * multiplier + return &cloned +} + // UsageTokens 使用的token数量 type UsageTokens struct { InputTokens int @@ -167,6 +205,27 @@ type CostBreakdown struct { LongContextBillingApplied bool } +func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) { + if cost == nil || multiplier == 1 { + return + } + cost.InputCost *= multiplier + cost.ImageInputCost *= multiplier + cost.OutputCost *= multiplier + cost.ImageOutputCost *= multiplier + cost.CacheCreationCost *= multiplier + cost.CacheReadCost *= multiplier + cost.TotalCost *= multiplier + cost.ActualCost *= multiplier +} + +func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 { + if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil { + return 1 + } + return resolved.channelPricing.TimePricing.MultiplierAt(at) +} + // ErrModelPricingUnavailable indicates that none of the configured pricing // sources can price the requested model. var ErrModelPricingUnavailable = errors.New("pricing not found") @@ -260,10 +319,10 @@ func (s *BillingService) initFallbackPricing() { // Claude 4.7 Opus (暂与4.6同价,待官方定价更新) s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"] - // Claude 4.8 Opus / Claude Opus 5(官方同价:$5 输入 / $25 输出 per MTok)。 + // Claude 4.8 Opus / Claude Opus 5(标准 $5/$25,Fast $10/$50 per MTok)。 // 缺少这两条时 getFallbackPricing 会掉到 claude-3-opus($15/$75),造成 3 倍超收。 - s.fallbackPrices["claude-opus-4.8"] = s.fallbackPrices["claude-opus-4.7"] - s.fallbackPrices["claude-opus-5"] = s.fallbackPrices["claude-opus-4.8"] + s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2) + s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2) // Gemini 3.1 Pro s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{ @@ -299,9 +358,31 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } - // GPT-5.5 / GPT-5.5 Pro 暂无独立定价,回退到 GPT-5.4。 - s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"] - s.fallbackPrices["gpt-5.5-pro"] = s.fallbackPrices["gpt-5.4"] + // OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。 + // Source: https://platform.openai.com/docs/pricing + s.fallbackPrices["gpt-5.5"] = pricingWithPriorityMultiplier(&ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + // 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。 + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: openAIGPT54LongContextInputThreshold, + LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, + LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + }, 2.5) + // GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。 + s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{ + InputPricePerToken: 30e-6, + OutputPricePerToken: 180e-6, + // 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。 + CacheCreationPricePerToken: 30e-6, + CacheReadPricePerToken: 30e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: openAIGPT54LongContextInputThreshold, + LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, + LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ @@ -587,7 +668,8 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } - // xAI Grok 4.5: $2 input / $0.30 cached input / $6 output below 200k. + // xAI Grok 4.5: $2 input / $0.30 cached input / $6 output below 200k; + // long-context rates are $4 / $0.60 / $12 (>=200k prompt tokens). s.fallbackPrices["grok-4.5"] = &ModelPricing{ InputPricePerToken: 2e-6, OutputPricePerToken: 6e-6, @@ -599,9 +681,8 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 2, } - // xAI Grok 4.6 (docs.x.ai/developers/models: $2 input / $0.50 cached input / - // $6 output per MTok under 200k prompt tokens; ≥200k is 2× on input, - // cached input, and output). + // xAI Grok 4.6: $2 input / $0.50 cached input / $6 output below 200k; + // long-context rates are $4 / $1 / $12 (>=200k prompt tokens). s.fallbackPrices["grok-4.6"] = &ModelPricing{ InputPricePerToken: 2e-6, OutputPricePerToken: 6e-6, @@ -613,7 +694,8 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 2, } - // xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k. + // xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k; + // long-context rates are $2.50 / $0.40 / $5. s.fallbackPrices["grok-4.3"] = &ModelPricing{ InputPricePerToken: 1.25e-6, OutputPricePerToken: 2.5e-6, @@ -624,6 +706,33 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: 2, LongContextOutputMultiplier: 2, } + // Grok 4.20 variants share the official $1.25 / $0.20 / $2.50 card + // (and $2.50 / $0.40 / $5 long-context rates) with Grok 4.3. + s.fallbackPrices["grok-4.20"] = &ModelPricing{ + InputPricePerToken: 1.25e-6, + OutputPricePerToken: 2.5e-6, + CacheReadPricePerToken: 0.2e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, + } + + // Keep legacy Grok 3 Mini requests on their own historical xAI price card; + // otherwise the generic Grok fallback bills them as Grok 4.5. + s.fallbackPrices["grok-3-mini"] = &ModelPricing{ + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 0.50e-6, + CacheReadPricePerToken: 0.075e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["grok-3-mini-fast"] = &ModelPricing{ + InputPricePerToken: 0.60e-6, + OutputPricePerToken: 4e-6, + CacheReadPricePerToken: 0.15e-6, + SupportsCacheBreakdown: false, + } // xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input / // $2 output per MTok). Composer is available only through Grok Build and // has no standalone public API rate card, so its aliases use this coding @@ -829,17 +938,22 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { } switch modelLower { - case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest": - return s.fallbackPrices["grok-4.5"] - case "grok-4.6", "grok-4.6-latest": + case "grok", "grok-latest", "grok-4.6", "grok-4.6-latest": return s.fallbackPrices["grok-4.6"] - case "grok-4.3", - "grok-4.20-0309-reasoning", + case "grok-4.5", "grok-4.5-latest": + return s.fallbackPrices["grok-4.5"] + case "grok-3-mini": + return s.fallbackPrices["grok-3-mini"] + case "grok-3-mini-fast": + return s.fallbackPrices["grok-3-mini-fast"] + case "grok-4.3": + return s.fallbackPrices["grok-4.3"] + case "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-4.20-multi-agent-0309", "grok-4.20-reasoning", "grok-4.20-non-reasoning": - return s.fallbackPrices["grok-4.3"] + return s.fallbackPrices["grok-4.20"] case "grok-build", "grok-build-latest", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5": return s.fallbackPrices["grok-build-0.1"] } @@ -857,7 +971,7 @@ func (s *BillingService) grokUnknownTextFamilyFallback(model string) *ModelPrici if s == nil || !isGrokUnknownTextFamilyModel(model) { return nil } - return s.fallbackPrices["grok-4.5"] + return s.fallbackPrices["grok-4.6"] } func isGrokUnknownTextFamilyModel(model string) bool { @@ -988,25 +1102,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing // 防止修改 fallbackPrices 中的共享指针 cloned := *pricing pricing = &cloned - if channelPricing.InputPrice != nil { - pricing.InputPricePerToken = *channelPricing.InputPrice - pricing.InputPricePerTokenPriority = *channelPricing.InputPrice - } - if channelPricing.OutputPrice != nil { - pricing.OutputPricePerToken = *channelPricing.OutputPrice - pricing.OutputPricePerTokenPriority = *channelPricing.OutputPrice - } - if channelPricing.CacheWritePrice != nil { - pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice - pricing.CacheCreationPricePerTokenPriority = *channelPricing.CacheWritePrice - pricing.CacheCreationPriceExplicit = true - pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice - pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice - } - if channelPricing.CacheReadPrice != nil { - pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice - pricing.CacheReadPricePerTokenPriority = *channelPricing.CacheReadPrice - } + applyChannelTokenPriceOverrides(pricing, channelPricing) + pricing.FastMultiplier = channelPricing.FastMultiplier + pricing.FlexMultiplier = channelPricing.FlexMultiplier if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1017,6 +1115,45 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing return pricing, nil } +// channelTierOverridePrice applies a Standard-tier override while preserving +// an explicit model-catalog Fast/Priority ratio. If the catalog has no tier +// price, generic service-tier defaults remain responsible for the fallback. +func channelTierOverridePrice(baseStandard, baseTier, channelStandard float64) float64 { + if baseStandard > 0 && baseTier > 0 { + return channelStandard * (baseTier / baseStandard) + } + return 0 +} + +func applyChannelTokenPriceOverrides(pricing *ModelPricing, channelPricing *ChannelModelPricing) { + if pricing == nil || channelPricing == nil { + return + } + if channelPricing.InputPrice != nil { + priority := channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *channelPricing.InputPrice) + pricing.InputPricePerToken = *channelPricing.InputPrice + pricing.InputPricePerTokenPriority = priority + } + if channelPricing.OutputPrice != nil { + priority := channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *channelPricing.OutputPrice) + pricing.OutputPricePerToken = *channelPricing.OutputPrice + pricing.OutputPricePerTokenPriority = priority + } + if channelPricing.CacheWritePrice != nil { + priority := channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *channelPricing.CacheWritePrice) + pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = priority + pricing.CacheCreationPriceExplicit = true + pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice + pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice + } + if channelPricing.CacheReadPrice != nil { + priority := channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *channelPricing.CacheReadPrice) + pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice + pricing.CacheReadPricePerTokenPriority = priority + } +} + // --- 统一计费入口 --- // CostInput 统一计费输入 @@ -1030,6 +1167,7 @@ type CostInput struct { UsageUnits float64 // 音频等连续计量单位(分钟/小时/百万字符) SizeTier string // 按次/图片模式的层级标签("1K","2K","4K","HD" 等) RateMultiplier float64 + PricingAt time.Time // 渠道分时定价使用的计费时刻 ServiceTier string // "priority","flex","" 等 Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) @@ -1091,20 +1229,31 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { totalContext := input.Tokens.InputTokens + input.Tokens.CacheCreationTokens + input.Tokens.CacheReadTokens - pricing := input.Resolver.GetIntervalPricing(resolved, totalContext) + // 分组开关是统一入口;账号 API 开关保留为额外开启能力,但 false 不否决分组配置。 + contextTierPricingEnabled := resolved.longContextPricingEnabled + if input.LongContextBillingEnabled != nil && *input.LongContextBillingEnabled { + contextTierPricingEnabled = true + } + + pricingContext := totalContext + if !contextTierPricingEnabled { + // 渠道可能显式配置了第一档,也可能只配置高上下文档。用 1 token + // 选择最低档;未命中时自然回退到渠道基础价。 + pricingContext = 1 + } + pricing := input.Resolver.GetIntervalPricing(resolved, pricingContext) if pricing == nil { return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable) } pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing) - // 长上下文定价仅在无区间定价时应用(区间定价已包含上下文分层) - applyLongCtx := len(resolved.Intervals) == 0 && resolved.longContextPricingEnabled - if input.LongContextBillingEnabled != nil { - applyLongCtx = applyLongCtx && *input.LongContextBillingEnabled - } + // 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。 + applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled - return s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx), nil + breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) + applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) + return breakdown, nil } // computeTokenBreakdown 是 token 计费的核心逻辑,由 calculateTokenCost 和 calculateCostInternal 共用。 @@ -1140,7 +1289,7 @@ func (s *BillingService) computeTokenBreakdown( cacheCreationPrice = pricing.CacheCreationPricePerTokenPriority } } else { - tierMultiplier = serviceTierCostMultiplier(serviceTier) + tierMultiplier = configuredServiceTierMultiplier(serviceTier, pricing) } longContextPricingEligible := applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) @@ -1222,16 +1371,43 @@ func (s *BillingService) computeTokenBreakdown( // multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。 func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 { if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) { - if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 { + cacheCreation5mTokens, cacheCreation1hTokens := normalizeCacheCreationBreakdown(tokens) + if cacheCreation5mTokens == 0 && cacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 { // API 未返回 ephemeral 明细,回退到全部按 5m 单价计费 return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier } - return float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier + - float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier + return float64(cacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier + + float64(cacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier } return float64(tokens.CacheCreationTokens) * price * multiplier } +// normalizeCacheCreationBreakdown caps contradictory 5m/1h details at an explicitly +// positive aggregate while retaining their reported ratio as closely as integer tokens allow. +func normalizeCacheCreationBreakdown(tokens UsageTokens) (int, int) { + cacheCreation5mTokens := tokens.CacheCreation5mTokens + cacheCreation1hTokens := tokens.CacheCreation1hTokens + aggregate := tokens.CacheCreationTokens + if cacheCreation5mTokens < 0 { + cacheCreation5mTokens = 0 + } + if cacheCreation1hTokens < 0 { + cacheCreation1hTokens = 0 + } + if aggregate <= 0 || (cacheCreation5mTokens <= aggregate && cacheCreation1hTokens <= aggregate-cacheCreation5mTokens) { + return cacheCreation5mTokens, cacheCreation1hTokens + } + + detailTotal := float64(cacheCreation5mTokens) + float64(cacheCreation1hTokens) + normalized5mTokens := math.Round(float64(aggregate) * float64(cacheCreation5mTokens) / detailTotal) + if normalized5mTokens >= float64(aggregate) { + cacheCreation5mTokens = aggregate + } else { + cacheCreation5mTokens = int(normalized5mTokens) + } + return cacheCreation5mTokens, aggregate - cacheCreation5mTokens +} + // calculatePerRequestCost 按次/图片计费 func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { units := input.UsageUnits @@ -1327,7 +1503,8 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * (pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0) needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) - if !needsLongContextPolicy && !needsCacheCreationPolicy { + fastRatio := openAIModelFastPricingRatio(normalized) + if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing @@ -1350,9 +1527,45 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier } } + if fastRatio > 0 { + enforceOpenAIFastPricingRatio(&cloned, fastRatio) + } return &cloned } +// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority +// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast +// 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 +func openAIModelFastPricingRatio(normalized string) float64 { + switch normalized { + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna": + return 2.0 + case "gpt-5.5": + return 2.5 + default: + return 0 + } +} + +// enforceOpenAIFastPricingRatio 把 priority 档价格改写为「标准价 × ratio」。 +// 本地/远程 LiteLLM 目录可能只带官方旧口径(如 gpt-5.5 priority 仍标 2x), +// 直接采用会导致 Fast 模式少计费;这里按业务倍率兜底修正,且对已正确的 +// fallback 条目(2x/2.5x)是幂等的。computeTokenBreakdown 在 priority 价格 +// 存在时走显式档位价、不再叠加通用 tier 倍率,因此不会重复乘价。 +func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) { + if pricing == nil || ratio <= 0 { + return + } + pricing.InputPricePerTokenPriority = pricing.InputPricePerToken * ratio + pricing.OutputPricePerTokenPriority = pricing.OutputPricePerToken * ratio + if pricing.CacheReadPricePerToken > 0 { + pricing.CacheReadPricePerTokenPriority = pricing.CacheReadPricePerToken * ratio + } + if pricing.CacheCreationPricePerToken > 0 { + pricing.CacheCreationPricePerTokenPriority = pricing.CacheCreationPricePerToken * ratio + } +} + func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool { if pricing == nil || pricing.LongContextInputThreshold <= 0 { return false diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 80d07af44b..0f8bbacf9a 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -215,7 +215,7 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { cacheRead float64 longContext int }{ - {model: "gpt5.5", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000}, {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0}, {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0}, @@ -293,14 +293,40 @@ func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) { cost, err := svc.CalculateCost("gpt-5.5-pro", tokens, 1.0) require.NoError(t, err) - expectedInput := float64(tokens.InputTokens) * 2.5e-6 * 2.0 - expectedOutput := float64(tokens.OutputTokens) * 15e-6 * 1.5 + expectedInput := float64(tokens.InputTokens) * 30e-6 * 2.0 + expectedOutput := float64(tokens.OutputTokens) * 180e-6 * 1.5 require.InDelta(t, expectedInput, cost.InputCost, 1e-10) require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10) } +func TestFallbackPricing_OpenAIGPT55UsesOfficialPrices(t *testing.T) { + svc := newTestBillingService() + + pricing, err := svc.GetModelPricing("gpt-5.5") + require.NoError(t, err) + require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12) +} + +func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) { + svc := newTestBillingService() + + pricing, err := svc.GetModelPricing("gpt-5.5-pro") + require.NoError(t, err) + require.InDelta(t, 30e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 180e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.Zero(t, pricing.InputPricePerTokenPriority) + require.Zero(t, pricing.OutputPricePerTokenPriority) +} + // 回归测试 #2293:长上下文计费触发时,cache_read_tokens 也应应用 LongContextInputMultiplier。 // 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。 // 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。 @@ -1153,7 +1179,7 @@ func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) { func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { svc := newTestBillingService() - for _, model := range []string{"grok", "grok-latest", "grok-4.5", "grok-4.5-latest"} { + for _, model := range []string{"grok-4.5", "grok-4.5-latest"} { model := model t.Run(model, func(t *testing.T) { pricing, err := svc.GetModelPricing(model) @@ -1166,6 +1192,17 @@ func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { } } +func TestGetModelPricing_GrokBareAliasesUseGrok46(t *testing.T) { + svc := newTestBillingService() + for _, model := range []string{"grok", "grok-latest"} { + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12) + } +} + func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) { svc := newTestBillingService() @@ -1185,6 +1222,25 @@ func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) { } } +func TestGetModelPricing_GrokOfficialFamilyCards(t *testing.T) { + svc := newTestBillingService() + for _, tc := range []struct { + model string + input, cached, output float64 + }{ + {"grok-4.3", 1.25e-6, 0.2e-6, 2.5e-6}, + {"grok-4.20-0309-reasoning", 1.25e-6, 0.2e-6, 2.5e-6}, + {"grok-build-0.1", 1e-6, 0.2e-6, 2e-6}, + } { + p, err := svc.GetModelPricing(tc.model) + require.NoError(t, err, tc.model) + require.InDelta(t, tc.input, p.InputPricePerToken, 1e-12) + require.InDelta(t, tc.cached, p.CacheReadPricePerToken, 1e-12) + require.InDelta(t, tc.output, p.OutputPricePerToken, 1e-12) + require.Equal(t, 200000, p.LongContextInputThreshold) + } +} + func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing.T) { svc := newTestBillingService() resolver := NewModelPricingResolver(nil, svc) @@ -1208,9 +1264,9 @@ func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing. require.InDelta(t, disabled.OutputCost*2, enabled.OutputCost, 1e-12) } -func TestGetModelPricing_UnknownGrokTextFallsBackToGrok45(t *testing.T) { +func TestGetModelPricing_UnknownGrokTextFallsBackToGrok46(t *testing.T) { svc := newTestBillingService() - baseline, err := svc.GetModelPricing("grok-4.5") + baseline, err := svc.GetModelPricing("grok-4.6") require.NoError(t, err) for _, model := range []string{"grok-5", "grok-5-latest", "x-ai/grok-7", "grok-4.7-beta"} { @@ -1328,6 +1384,122 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) { require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10) } +func TestComputeCacheCreationCost_CapsContradictoryBreakdownAtAggregate(t *testing.T) { + svc := &BillingService{} + pricing := &ModelPricing{ + SupportsCacheBreakdown: true, + CacheCreation5mPrice: 1, + CacheCreation1hPrice: 1, + } + + tokens := UsageTokens{ + CacheCreationTokens: 463184, + CacheCreation5mTokens: 463184, + CacheCreation1hTokens: 463184, + } + + cost := svc.computeCacheCreationCost(pricing, tokens, 0, 1) + require.Equal(t, float64(tokens.CacheCreationTokens), cost, + "billed cache-creation token equivalent must not exceed the positive aggregate") +} + +func TestNormalizeCacheCreationBreakdown_BillingSafetyInvariant(t *testing.T) { + tests := []struct { + name string + tokens UsageTokens + want5m int + want1h int + }{ + { + name: "preserves ratio when capping", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 90, CacheCreation1hTokens: 60}, + want5m: 60, + want1h: 40, + }, + { + name: "details below aggregate unchanged", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 60}, + want5m: 30, + want1h: 60, + }, + { + name: "absent 5m detail unchanged", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation1hTokens: 60}, + want5m: 0, + want1h: 60, + }, + { + name: "absent 1h detail unchanged", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30}, + want5m: 30, + want1h: 0, + }, + { + name: "negative detail clamped", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 60}, + want5m: 0, + want1h: 60, + }, + { + name: "negative detail cannot hide oversized positive detail", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 150}, + want5m: 0, + want1h: 100, + }, + { + name: "integer boundary details capped without overflow", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: int(^uint(0) >> 1)}, + want5m: 50, + want1h: 50, + }, + { + name: "integer boundary aggregate avoids float conversion overflow", + tokens: UsageTokens{CacheCreationTokens: int(^uint(0) >> 1), CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: 1}, + want5m: int(^uint(0) >> 1), + want1h: 0, + }, + { + name: "zero aggregate unchanged", + tokens: UsageTokens{CacheCreation5mTokens: 90, CacheCreation1hTokens: 60}, + want5m: 90, + want1h: 60, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got5m, got1h := normalizeCacheCreationBreakdown(tt.tokens) + require.Equal(t, tt.want5m, got5m) + require.Equal(t, tt.want1h, got1h) + }) + } +} + +func TestComputeCacheCreationCost_PreservesZeroDetailFallback(t *testing.T) { + svc := &BillingService{} + pricing := &ModelPricing{ + SupportsCacheBreakdown: true, + CacheCreation5mPrice: 4e-6, + CacheCreation1hPrice: 5e-6, + } + + tests := []struct { + name string + tokens UsageTokens + }{ + {name: "zero details", tokens: UsageTokens{CacheCreationTokens: 100}}, + {name: "one negative detail", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25}}, + {name: "both negative details", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25, CacheCreation1hTokens: -75}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cost := svc.computeCacheCreationCost(pricing, tt.tokens, 0, 1) + require.InDelta(t, 100*4e-6, cost, 1e-12) + }) + } +} + func TestCalculateCost_LargeTokenCount(t *testing.T) { svc := newTestBillingService() @@ -1594,9 +1766,10 @@ func TestGetModelPricingWithChannel_OverrideInputPriceOnly(t *testing.T) { pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // InputPrice overridden (both normal and priority) + // InputPrice overridden. claude-sonnet-4 has no catalog priority price, so + // the priority slot is zeroed and serviceTierCostMultiplier owns the surcharge. require.InDelta(t, 99e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 99e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.InputPricePerTokenPriority) // OutputPrice unchanged (claude-sonnet-4 fallback = 15e-6) require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12) @@ -1611,9 +1784,9 @@ func TestGetModelPricingWithChannel_OverrideOutputPriceOnly(t *testing.T) { pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // OutputPrice overridden + // OutputPrice overridden; no catalog priority price to scale, so the slot is zeroed. require.InDelta(t, 88e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 88e-6, pricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.OutputPricePerTokenPriority) // InputPrice unchanged (claude-sonnet-4 fallback = 3e-6) require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12) @@ -1633,15 +1806,18 @@ func TestGetModelPricingWithChannel_OverrideAllFields(t *testing.T) { require.NoError(t, err) require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 10e-6, pricing.InputPricePerTokenPriority, 1e-12) require.InDelta(t, 20e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 20e-6, pricing.OutputPricePerTokenPriority, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12) - require.InDelta(t, 1e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12) + + // claude-sonnet-4 carries no catalog Fast/Priority tier, so every priority + // slot stays zero and computeTokenBreakdown falls back to the 2x default. + require.Zero(t, pricing.InputPricePerTokenPriority) + require.Zero(t, pricing.OutputPricePerTokenPriority) + require.Zero(t, pricing.CacheReadPricePerTokenPriority) } func TestGetModelPricingWithChannel_CacheWritePriceAffects5mAnd1h(t *testing.T) { @@ -1668,9 +1844,27 @@ func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // CacheReadPrice should set both normal and priority + // CacheReadPrice sets the standard slot; the priority slot is zeroed because + // claude-sonnet-4 has no catalog tier ratio to preserve. require.InDelta(t, 2e-6, pricing.CacheReadPricePerToken, 1e-12) - require.InDelta(t, 2e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.CacheReadPricePerTokenPriority) +} + +// 目录带 tier 价时,渠道覆盖必须按目录比例换算 priority 价,而不是归零。 +func TestGetModelPricingWithChannel_PreservesCatalogPriorityRatio(t *testing.T) { + svc := newTestBillingService() + + // gpt-5.4 目录价:input 2.5/5(2x),output 15/30(2x)。 + pricing, err := svc.GetModelPricingWithChannel("gpt-5.4", &ChannelModelPricing{ + InputPrice: testPtrFloat64(4e-6), + OutputPrice: testPtrFloat64(30e-6), + }) + require.NoError(t, err) + + require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 8e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 60e-6, pricing.OutputPricePerTokenPriority, 1e-12) } func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) { diff --git a/backend/internal/service/billing_service_unified_test.go b/backend/internal/service/billing_service_unified_test.go index eabbab3dfc..e61a899551 100644 --- a/backend/internal/service/billing_service_unified_test.go +++ b/backend/internal/service/billing_service_unified_test.go @@ -169,6 +169,154 @@ func TestCalculateCostUnified_ImageMode(t *testing.T) { require.Equal(t, string(BillingModeImage), cost.BillingMode) } +func channelTimeResolvedForTest(base *ModelPricing, intervals []PricingInterval) *ResolvedPricing { + return &ResolvedPricing{ + Mode: BillingModeToken, + BasePricing: base, + Intervals: intervals, + Source: PricingSourceChannel, + channelPricing: &ChannelModelPricing{ + BillingMode: BillingModeToken, + TimePricing: &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{ + StartTime: "09:00", + EndTime: "12:00", + Multiplier: 2, + }}, + }, + }, + longContextPricingEnabled: true, + } +} + +func TestCalculateCostUnified_ChannelTimePricingScalesBaseAndActualCost(t *testing.T) { + billing := NewBillingService(&config.Config{}, nil) + resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil) + + cost, err := billing.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "model", + Tokens: UsageTokens{InputTokens: 1000}, + RateMultiplier: 0.8, + Resolver: &ModelPricingResolver{}, + Resolved: resolved, + PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.InDelta(t, 2.0, cost.InputCost, 1e-12) + require.InDelta(t, 2.0, cost.TotalCost, 1e-12) + require.InDelta(t, 1.6, cost.ActualCost, 1e-12) +} + +func TestCalculateCostUnified_ChannelTimePricingScalesMatchingInterval(t *testing.T) { + intervalInputPrice := 0.003 + resolved := channelTimeResolvedForTest( + &ModelPricing{InputPricePerToken: 0.001}, + []PricingInterval{{MinTokens: 0, InputPrice: &intervalInputPrice}}, + ) + billing := NewBillingService(&config.Config{}, nil) + + cost, err := billing.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "model", + Tokens: UsageTokens{InputTokens: 1000}, + Resolver: &ModelPricingResolver{}, + Resolved: resolved, + PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.InDelta(t, 6.0, cost.InputCost, 1e-12) + require.InDelta(t, 6.0, cost.TotalCost, 1e-12) +} + +func TestCalculateCostUnified_ChannelTimePricingScalesBaseOnUnmatchedInterval(t *testing.T) { + intervalInputPrice := 0.003 + resolved := channelTimeResolvedForTest( + &ModelPricing{InputPricePerToken: 0.001}, + []PricingInterval{{MinTokens: 2000, InputPrice: &intervalInputPrice}}, + ) + billing := NewBillingService(&config.Config{}, nil) + + cost, err := billing.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "model", + Tokens: UsageTokens{InputTokens: 1000}, + Resolver: &ModelPricingResolver{}, + Resolved: resolved, + PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.InDelta(t, 2.0, cost.InputCost, 1e-12) + require.InDelta(t, 2.0, cost.TotalCost, 1e-12) +} + +func TestCalculateCostUnified_ChannelTimePricingDoesNotApplyToGroupPricing(t *testing.T) { + resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil) + resolved.Source = PricingSourceGroup + billing := NewBillingService(&config.Config{}, nil) + + cost, err := billing.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "model", + Tokens: UsageTokens{InputTokens: 1000}, + Resolver: &ModelPricingResolver{}, + Resolved: resolved, + PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.InDelta(t, 1.0, cost.TotalCost, 1e-12) +} + +func TestCalculateCostUnified_ChannelTimePricingDoesNotApplyOutsideMatchingTime(t *testing.T) { + resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil) + billing := NewBillingService(&config.Config{}, nil) + + for _, pricingAt := range []time.Time{ + time.Time{}, + time.Date(2026, 8, 17, 5, 0, 0, 0, time.UTC), + } { + cost, err := billing.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "model", + Tokens: UsageTokens{InputTokens: 1000}, + Resolver: &ModelPricingResolver{}, + Resolved: resolved, + PricingAt: pricingAt, + }) + require.NoError(t, err) + require.InDelta(t, 1.0, cost.TotalCost, 1e-12) + } +} + +func TestApplyCostBreakdownMultiplierScalesAllMonetaryFields(t *testing.T) { + cost := &CostBreakdown{ + InputCost: 1, + ImageInputCost: 2, + OutputCost: 3, + ImageOutputCost: 4, + CacheCreationCost: 5, + CacheReadCost: 6, + TotalCost: 21, + ActualCost: 42, + BillingMode: string(BillingModeToken), + LongContextBillingApplied: true, + } + + applyCostBreakdownMultiplier(cost, 1.5) + + require.InDelta(t, 1.5, cost.InputCost, 1e-12) + require.InDelta(t, 3.0, cost.ImageInputCost, 1e-12) + require.InDelta(t, 4.5, cost.OutputCost, 1e-12) + require.InDelta(t, 6.0, cost.ImageOutputCost, 1e-12) + require.InDelta(t, 7.5, cost.CacheCreationCost, 1e-12) + require.InDelta(t, 9.0, cost.CacheReadCost, 1e-12) + require.InDelta(t, 31.5, cost.TotalCost, 1e-12) + require.InDelta(t, 63.0, cost.ActualCost, 1e-12) + require.Equal(t, string(BillingModeToken), cost.BillingMode) + require.True(t, cost.LongContextBillingApplied) +} + // TestCalculateCostUnified_RateMultiplierZeroProducesZero 锁定新行为: // 保存时强制 > 0;若 0 仍泄漏到计费层,按 0 计费(而非历史上的 1.0)。 func TestCalculateCostUnified_RateMultiplierZeroProducesZero(t *testing.T) { diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go new file mode 100644 index 0000000000..efd8aa6606 --- /dev/null +++ b/backend/internal/service/billing_token_cost_request.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "time" +) + +// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。 +// +// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier, +// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。 +// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用; +// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。 +type LegacyLongContextRule struct { + Threshold int + Multiplier float64 +} + +const ( + geminiLegacyLongContextThreshold = 200000 + geminiLegacyLongContextMultiplier = 2.0 +) + +// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。 +func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule { + if platform == PlatformGemini { + return &LegacyLongContextRule{ + Threshold: geminiLegacyLongContextThreshold, + Multiplier: geminiLegacyLongContextMultiplier, + } + } + return nil +} + +// TokenCostRequest 通用网关 token 计费请求。 +type TokenCostRequest struct { + Ctx context.Context + Model string + Group *Group + Tokens UsageTokens + RateMultiplier float64 + PricingAt time.Time + ServiceTier string + Resolver *ModelPricingResolver + // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 + Resolved *ResolvedPricing + // LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。 + LegacyLongContext *LegacyLongContextRule +} + +// legacyLongContextApplies 判定请求是否走旧长上下文规则: +// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。 +func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool { + if rule == nil || rule.Threshold <= 0 { + return false + } + if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { + return false + } + return group == nil || group.LongContextPricingEnabled +} + +// CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用: +// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中); +// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费; +// 3. 否则有解析器与分组 → 统一计费(内置目录定价); +// 4. 否则按模型目录直接计费。 +// +// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。 +func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) { + resolved := req.Resolved + if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } + if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) { + return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier, + req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier) + } + if req.Resolver != nil && req.Group != nil { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } + return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier) +} + +func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput { + input := CostInput{ + Ctx: req.Ctx, + Model: req.Model, + Group: req.Group, + Tokens: req.Tokens, + RequestCount: 1, + RateMultiplier: req.RateMultiplier, + PricingAt: req.PricingAt, + ServiceTier: req.ServiceTier, + Resolver: req.Resolver, + Resolved: resolved, + } + if req.Group != nil { + gid := req.Group.ID + input.GroupID = &gid + } + return input +} diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go new file mode 100644 index 0000000000..8392b8cf49 --- /dev/null +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -0,0 +1,147 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// newTokenCostTestEnv 构造带渠道定价的计费环境:group 100 挂一个渠道,定价由 pricing 指定。 +func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelModelPricing, catalog *PricingService) (*BillingService, *ModelPricingResolver) { + t.Helper() + repo := &mockChannelRepository{ + listAllFn: func(_ context.Context) ([]Channel, error) { + return []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{100}, ModelPricing: pricing, + }}, nil + }, + getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) { + return map[int64]string{100: groupPlatform}, nil + }, + } + cs := NewChannelService(repo, nil, nil, nil) + bs := NewBillingService(&config.Config{}, catalog) + return bs, NewModelPricingResolver(cs, bs) +} + +func geminiCatalogStub() *PricingService { + return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ + "gemini-2.5-pro": { + Mode: "chat", + InputCostPerToken: 1.25e-6, + OutputCostPerToken: 10e-6, + CacheReadInputTokenCost: 0.3125e-6, + }, + }) +} + +func TestLegacyLongContextRule_OnlyGemini(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + rule := bs.LegacyLongContextRule(PlatformGemini) + require.NotNil(t, rule) + require.Equal(t, 200000, rule.Threshold) + require.InDelta(t, 2.0, rule.Multiplier, 1e-12) + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} { + require.Nil(t, bs.LegacyLongContextRule(platform), platform) + } +} + +func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{ + Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6), + }}, geminiCatalogStub()) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceChannel, resolved.Source) + + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini), + }) + require.NoError(t, err) + want, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, + RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.Equal(t, want, got) + // 渠道平价 10e-6 × 300K,旧规则未叠加 + require.InDelta(t, 3.0, got.InputCost, 1e-9) +} + +func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + rule := bs.LegacyLongContextRule(PlatformGemini) + + for _, enabled := range []bool{true, false} { + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceLiteLLM, resolved.Source) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, LegacyLongContext: rule, + }) + require.NoError(t, err) + if enabled { + want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier) + require.NoError(t, err) + require.Equal(t, want, got) + // 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。 + // 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。 + require.InDelta(t, 0.51, got.ActualCost, 1e-9) + require.True(t, got.LongContextBillingApplied) + } else { + want, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, + RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.Equal(t, want, got) + require.InDelta(t, 0.385, got.ActualCost, 1e-9) + require.False(t, got.LongContextBillingApplied) + } + } +} + +func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil) + group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true} + gid := group.ID + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gpt-5.4", GroupID: &gid, Group: group}) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gpt-5.4", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + want, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "gpt-5.4", GroupID: &gid, Group: group, Tokens: tokens, + RequestCount: 1, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + require.Equal(t, want, got) + // 目录阶梯:超 272K 整单输入 ×2 + require.InDelta(t, 300000*2.5e-6*2, got.InputCost, 1e-9) + require.True(t, got.LongContextBillingApplied) +} + +func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{Model: "gpt-5.4", Tokens: tokens, RateMultiplier: 1}) + require.NoError(t, err) + want, err := bs.CalculateCost("gpt-5.4", tokens, 1) + require.NoError(t, err) + require.Equal(t, want, got) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 4d5523b0a3..3e300edf99 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -87,38 +87,59 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 `json:"id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) - Models []string `json:"models"` - BillingMode BillingMode `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []PricingInterval `json:"intervals"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// ChannelTimePricing 渠道模型定价的分时倍率配置。 +type ChannelTimePricing struct { + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only,omitempty"` + Periods []ChannelTimePricingPeriod `json:"periods"` +} + +// ChannelTimePricingPeriod 是秒级的左闭右开分时倍率区间,并兼容历史 HH:mm 数据。 +type ChannelTimePricingPeriod struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Multiplier float64 `json:"multiplier"` } // PricingInterval 定价区间(token 区间 / 按次分层 / 图片分辨率分层) type PricingInterval struct { - ID int64 `json:"id,omitempty"` - PricingID int64 `json:"pricing_id,omitempty"` - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + PricingID int64 `json:"pricing_id,omitempty"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // IsActive 判断渠道是否启用 @@ -195,6 +216,15 @@ func (p ChannelModelPricing) Clone() ChannelModelPricing { cp.Intervals = make([]PricingInterval, len(p.Intervals)) copy(cp.Intervals, p.Intervals) } + if p.TimePricing != nil { + cp.TimePricing = &ChannelTimePricing{ + Timezone: p.TimePricing.Timezone, + WeekdaysOnly: p.TimePricing.WeekdaysOnly, + } + if p.TimePricing.Periods != nil { + cp.TimePricing.Periods = append([]ChannelTimePricingPeriod(nil), p.TimePricing.Periods...) + } + } return cp } @@ -338,7 +368,7 @@ func validateSingleInterval(iv *PricingInterval, idx int) error { return validateIntervalPrices(iv, idx) } -// validateIntervalPrices 校验区间内所有价格字段 >= 0 +// validateIntervalPrices 校验区间价格 >= 0、倍率 > 0。 func validateIntervalPrices(iv *PricingInterval, idx int) error { prices := []struct { name string @@ -355,6 +385,20 @@ func validateIntervalPrices(iv *PricingInterval, idx int) error { return fmt.Errorf("interval #%d: %s must be >= 0", idx+1, p.name) } } + multipliers := []struct { + name string + val *float64 + }{ + {"input_multiplier", iv.InputMultiplier}, + {"output_multiplier", iv.OutputMultiplier}, + {"cache_write_multiplier", iv.CacheWriteMultiplier}, + {"cache_read_multiplier", iv.CacheReadMultiplier}, + } + for _, multiplier := range multipliers { + if multiplier.val != nil && *multiplier.val <= 0 { + return fmt.Errorf("interval #%d: %s must be > 0", idx+1, multiplier.name) + } + } return nil } diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index eeaf7dac2e..6face6e1db 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -90,7 +90,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, ch.normalizeBillingModelSource() supported := ch.SupportedModels() - s.fillGlobalPricingFallback(supported) + fillGlobalPricingFallback(s.pricingService, supported) out = append(out, AvailableChannel{ ID: ch.ID, @@ -117,16 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 s.pricingService 为 nil(测试场景),跳过回落。 -func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) { - if s.pricingService == nil { +// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。 +func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { + if pricingService == nil { return } for i := range models { if !pricingNeedsFallback(models[i].Pricing) { continue } - lp := s.pricingService.GetModelPricing(models[i].Name) + lp := pricingService.GetModelPricing(models[i].Name) if lp == nil { continue } diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index d59e587ecd..2b7b23f7e5 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -255,7 +255,7 @@ func TestFillGlobalPricingFallback_NilPricing(t *testing.T) { models := []SupportedModel{ {Name: "claude-opus-4-5", Platform: "anthropic"}, } - svc.fillGlobalPricingFallback(models) + fillGlobalPricingFallback(svc.pricingService, models) require.NotNil(t, models[0].Pricing) require.NotNil(t, models[0].Pricing.InputPrice) require.InDelta(t, 5e-6, *models[0].Pricing.InputPrice, 1e-12) @@ -281,7 +281,7 @@ func TestFillGlobalPricingFallback_EmptyPricingFillsFromLiteLLM(t *testing.T) { }, }, } - svc.fillGlobalPricingFallback(models) + fillGlobalPricingFallback(svc.pricingService, models) require.NotNil(t, models[0].Pricing) require.Equal(t, BillingModeImage, models[0].Pricing.BillingMode) require.NotNil(t, models[0].Pricing.ImageOutputPrice) @@ -302,7 +302,7 @@ func TestFillGlobalPricingFallback_KeepsExistingPrice(t *testing.T) { models := []SupportedModel{ {Name: "served-model", Platform: "anthropic", Pricing: existing}, } - svc.fillGlobalPricingFallback(models) + fillGlobalPricingFallback(svc.pricingService, models) require.Same(t, existing, models[0].Pricing) } diff --git a/backend/internal/service/channel_monitor_aggregator.go b/backend/internal/service/channel_monitor_aggregator.go index 09020f5fa4..b6f632bf4b 100644 --- a/backend/internal/service/channel_monitor_aggregator.go +++ b/backend/internal/service/channel_monitor_aggregator.go @@ -204,6 +204,8 @@ func buildStatusSummary( if l, ok := latestByModel[primary]; ok { summary.PrimaryStatus = l.Status summary.PrimaryLatencyMs = l.LatencyMs + // 配额快照只挂主模型行(quota 模式唯一行 / quota_probe 的主行)。 + summary.LatestQuota = l.Quota } if a, ok := availByModel[primary]; ok { summary.Availability7d = a.AvailabilityPct @@ -242,6 +244,7 @@ func buildUserViewFromSummary( } if primaryLatest != nil { view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs + view.LatestQuota = primaryLatest.Quota } return view } diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 910a6e80d9..71177db0bd 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -170,6 +170,11 @@ type providerAdapter struct { var providerAdapters = map[string]providerAdapter{ MonitorProviderOpenAI: providerOpenAIChatAdapter, MonitorProviderGrok: providerGrokChatAdapter, + // 国产 3 家(配额模式引入):均为 OpenAI 兼容 Chat Completions, + // 仅智谱路径前缀不同(/api/paas/v4/chat/completions)。 + MonitorProviderKimi: providerKimiChatAdapter, + MonitorProviderZhipu: providerZhipuChatAdapter, + MonitorProviderDeepseek: providerDeepseekChatAdapter, MonitorProviderAnthropic: { buildPath: func(string) string { return providerAnthropicPath }, buildBody: func(model, prompt string) ([]byte, error) { @@ -212,6 +217,15 @@ var providerOpenAIChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPat //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 var providerGrokChatAdapter = newOpenAICompatibleChatAdapter(providerGrokPath) +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerKimiChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath) + +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath) + +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath) + func newOpenAICompatibleChatAdapter(path string) providerAdapter { return providerAdapter{ buildPath: func(string) string { return path }, @@ -257,13 +271,6 @@ func providerAdapterFor(provider, apiMode string) (providerAdapter, string, bool return adapter, MonitorAPIModeChatCompletions, ok } -// isSupportedProvider 校验 provider 字符串是否在 adapter 表中。 -// 供 validate.go 的 validateProvider 复用,避免两份 switch 漂移。 -func isSupportedProvider(p string) bool { - _, ok := providerAdapters[p] - return ok -} - // callProvider 通过 providerAdapters 分发到具体实现。 // opts 承载用户的自定义 headers / body 覆盖(可为 nil)。 // @@ -447,6 +454,10 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{ MonitorProviderGrok: {"model": true, "messages": true, "stream": true}, MonitorProviderAnthropic: {"model": true, "messages": true}, MonitorProviderGemini: {"contents": true}, + // 国产 3 家与 OpenAI Chat Completions 同构。 + MonitorProviderKimi: {"model": true, "messages": true, "stream": true}, + MonitorProviderZhipu: {"model": true, "messages": true, "stream": true}, + MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true}, } func checkAPIMode(opts *CheckOptions) string { @@ -463,8 +474,20 @@ func bodyMergeDenyKey(provider, apiMode string) string { return provider } +// isOpenAICompatibleChatProvider 该 provider 的探活请求是否为 OpenAI Chat +// Completions 同构(replace 模式的 body 校验按 messages 必填处理)。 +func isOpenAICompatibleChatProvider(provider string) bool { + switch provider { + case MonitorProviderOpenAI, MonitorProviderGrok, + MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek: + return true + default: + return false + } +} + func validateReplaceRequestBody(provider, apiMode string, body map[string]any) error { - if provider != MonitorProviderOpenAI && provider != MonitorProviderGrok { + if !isOpenAICompatibleChatProvider(provider) { return nil } switch defaultAPIMode(apiMode) { diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 3375ee6ac9..2357646337 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -199,7 +199,7 @@ func TestGrokMonitorConfiguration(t *testing.T) { if err := validateProvider(MonitorProviderGrok); err != nil { t.Fatalf("grok provider should be supported: %v", err) } - if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, ""); got != MonitorDefaultGrokModel { + if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""); got != MonitorDefaultGrokModel { t.Fatalf("expected default Grok model %q, got %q", MonitorDefaultGrokModel, got) } if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeChatCompletions); err != nil { diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 6a41add298..6a2b9b92e8 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -45,10 +45,12 @@ const ( monitorChallengeMin = 1 monitorChallengeMax = 50 - // providerOpenAIPath OpenAI Chat Completions 路径。 + // providerOpenAIPath OpenAI Chat Completions 路径(Kimi / DeepSeek 同为 OpenAI 兼容)。 providerOpenAIPath = "/v1/chat/completions" // providerGrokPath Grok OpenAI-compatible Chat Completions 路径。 providerGrokPath = "/v1/chat/completions" + // providerZhipuPath 智谱 OpenAI 兼容 Chat Completions 路径(前缀与官方不同)。 + providerZhipuPath = "/api/paas/v4/chat/completions" // providerOpenAIResponsesPath OpenAI Responses API 路径。 providerOpenAIResponsesPath = "/v1/responses" // providerAnthropicPath Anthropic Messages 路径。 @@ -56,11 +58,42 @@ const ( // providerGeminiPathTemplate Gemini generateContent 路径模板(含 model 占位)。 providerGeminiPathTemplate = "/v1beta/models/%s:generateContent" - // MonitorProviderOpenAI / Anthropic / Gemini / Grok provider 字符串常量(也是 ent enum 的实际值)。 - MonitorProviderOpenAI = "openai" - MonitorProviderAnthropic = "anthropic" - MonitorProviderGemini = "gemini" - MonitorProviderGrok = "grok" + // MonitorProviderOpenAI 等 provider 字符串常量(也是 ent enum 的实际值)。 + // 后 4 个 provider(antigravity/kimi/zhipu/deepseek)为配额模式引入: + // antigravity 无探活 adapter(仅配额),其余 3 个复用 OpenAI 兼容探活。 + MonitorProviderOpenAI = "openai" + MonitorProviderAnthropic = "anthropic" + MonitorProviderGemini = "gemini" + MonitorProviderGrok = "grok" + MonitorProviderAntigravity = "antigravity" + MonitorProviderKimi = "kimi" + MonitorProviderZhipu = "zhipu" + MonitorProviderDeepseek = "deepseek" + + // MonitorCheckMode 检测模式(channel_monitors.check_mode)。 + // probe - LLM 探活(默认,原有行为) + // quota - 仅查关联账号用量/余额,零 LLM 成本 + // quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行) + MonitorCheckModeProbe = "probe" + MonitorCheckModeQuota = "quota" + MonitorCheckModeQuotaProbe = "quota_probe" + + // MonitorDefaultQuotaModel 是 quota 模式监控未显式指定模型时占位的虚拟模型名 + // (primary_model 列 NotEmpty,用 "quota" 让历史行/时间线机制无需特判)。 + MonitorDefaultQuotaModel = "quota" + + // monitorQuotaFetchCacheTTL 配额快照缓存时长。多个监控可能关联同一账号, + // 而 interval 最小 15s 且国产配额服务无缓存,TTL 防止打爆上游配额端点。 + monitorQuotaFetchCacheTTL = 5 * time.Minute + // monitorQuotaErrorCacheTTL 失败快照的负缓存时长:失败也短缓存,避免 + // 故障/凭据失效期间每次调度(最小 15s)都带真实凭据打上游;到期自动重试。 + monitorQuotaErrorCacheTTL = 60 * time.Second + // monitorQuotaFetchTimeout singleflight 内单次配额抓取的总超时 + // (脱离调用方 ctx,防止某个监控的取消波及共享同一账号的其他监控)。 + monitorQuotaFetchTimeout = 45 * time.Second + // monitorQuotaDegradedUsedPercent 任一用量窗口使用率超过该阈值时, + // 配额检查状态记为 degraded(对齐账号页展示阈值)。 + monitorQuotaDegradedUsedPercent = 90.0 // MonitorDefaultGrokModel 是新增 Grok 监控未显式指定模型时使用的轻量测活模型。 MonitorDefaultGrokModel = "grok-4.5" @@ -118,7 +151,19 @@ var ( "CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found", ) ErrChannelMonitorInvalidProvider = infraerrors.BadRequest( - "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok", + "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek", + ) + ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest( + "CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota", + ) + ErrChannelMonitorAccountRequired = infraerrors.BadRequest( + "CHANNEL_MONITOR_ACCOUNT_REQUIRED", "account_id is required for quota-based check_mode", + ) + ErrChannelMonitorProviderIncompatible = infraerrors.BadRequest( + "CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform", + ) + ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest( + "CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)", ) ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai", diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go new file mode 100644 index 0000000000..479762ae8d --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -0,0 +1,540 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "golang.org/x/sync/singleflight" +) + +// 渠道监控「配额模式」的配额抓取器。 +// +// 不直接对接上游,而是把账号侧现成的用量服务归一成 domain.MonitorQuotaSnapshot: +// - 海外 5 家(anthropic/openai/gemini/antigravity/grok)→ AccountUsageService.GetUsageForAccount +// - 国产 coding plan(kimi/zhipu/deepseek)→ CNProviderQuotaService.QueryUsageForAccount +// - 国产 payg(kimi/deepseek)→ CNProviderBalanceService.QueryBalanceForAccount +// (zhipu payg 无公开余额端点,探测会返回该错误,原样透出) +// 数据源统一接受已加载的 *Account:fetchUncached 路由前 GetByID 一次并传下去, +// 下游服务不再各自重载(每次 GetByID 含 proxies/groups 联查)。 +// +// Fetch 永不返回 error:所有失败都降级为 Success=false 的快照照常入库, +// 由 deriveQuotaCheckResult 推导为 failed/error 状态。 +// +// 多个监控可能关联同一账号,而 interval 最小 15s 且国产配额服务自身无缓存, +// 所以快照统一带 TTL 缓存(成功 monitorQuotaFetchCacheTTL、失败 +// monitorQuotaErrorCacheTTL 负缓存),防止打爆上游配额端点;同账号的并发 +// 抓取由 singleflight 合并为一次上游查询。 + +// monitorUsageSource 海外平台账号用量查询(AccountUsageService 天然满足)。 +// 传已加载的 *Account:fetchUncached 只 GetByID 一次,下游不再重复加载。 +type monitorUsageSource interface { + GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) +} + +// monitorCNQuotaSource 国产 coding plan 滚动窗口额度探测(CNProviderQuotaService 天然满足)。 +type monitorCNQuotaSource interface { + QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) +} + +// monitorCNBalanceSource 国产 payg 余额探测(CNProviderBalanceService 天然满足)。 +type monitorCNBalanceSource interface { + QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) +} + +// monitorAccountSource 账号加载(AccountRepository 天然满足)。 +type monitorAccountSource interface { + GetByID(ctx context.Context, id int64) (*Account, error) +} + +// ChannelMonitorQuotaFetcher 配额抓取器(成功/失败快照均带 TTL 缓存, +// 同账号并发抓取由 singleflight 合并)。 +type ChannelMonitorQuotaFetcher struct { + usage monitorUsageSource + cnQuota monitorCNQuotaSource + cnBalance monitorCNBalanceSource + accounts monitorAccountSource + // balanceThreshold cn_balance 余额告警阈值(与账号停调共用配置,见 monitorBalanceThreshold)。 + balanceThreshold float64 + + mu sync.Mutex + cache map[int64]monitorQuotaCacheEntry + flight singleflight.Group +} + +type monitorQuotaCacheEntry struct { + snapshot *domain.MonitorQuotaSnapshot + expiry time.Time +} + +// NewChannelMonitorQuotaFetcher 构造配额抓取器。 +// 参数取具体服务类型以便 wire 直连;单元测试在同包内用 struct 字面量注入 stub。 +func NewChannelMonitorQuotaFetcher( + usage *AccountUsageService, + cnQuota *CNProviderQuotaService, + cnBalance *CNProviderBalanceService, + accounts AccountRepository, + cfg *config.Config, +) *ChannelMonitorQuotaFetcher { + f := &ChannelMonitorQuotaFetcher{ + cache: make(map[int64]monitorQuotaCacheEntry), + balanceThreshold: monitorBalanceThreshold(cfg), + } + if usage != nil { + f.usage = usage + } + if cnQuota != nil { + f.cnQuota = cnQuota + } + if cnBalance != nil { + f.cnBalance = cnBalance + } + if accounts != nil { + f.accounts = accounts + } + return f +} + +// monitorBalanceThreshold 余额告警阈值,与账号停调(CNProviderBalanceCheckService) +// 共用 gateway.cn_providers.balance_threshold,保证监控 degraded 与调度器停调 +// 口径一致(任一币种达标即健康)。未配置/非正值时回退 viper 默认 0.5(config.go), +// 避免 0 阈值下「余额=0 也不告警」相对旧 `<=0` 判定的回归。 +func monitorBalanceThreshold(cfg *config.Config) float64 { + if cfg != nil && cfg.Gateway.CNProviders.BalanceThreshold > 0 { + return cfg.Gateway.CNProviders.BalanceThreshold + } + return 0.5 +} + +// LoadAccount 加载账号(不走缓存)。供 Create/Update 时校验 +// provider 与 account.platform 一致;账号不存在时返回错误。 +func (f *ChannelMonitorQuotaFetcher) LoadAccount(ctx context.Context, id int64) (*Account, error) { + if f == nil || f.accounts == nil { + return nil, fmt.Errorf("quota fetcher is not configured") + } + return f.accounts.GetByID(ctx, id) +} + +// Fetch 抓取账号的最新配额快照。永不返回 error:失败降级为 +// Success=false 快照(Error 带摘要),保证检测历史的时间线连续。 +func (f *ChannelMonitorQuotaFetcher) Fetch(ctx context.Context, accountID int64) *domain.MonitorQuotaSnapshot { + if f == nil { + // fail-closed:fetcher 未注入(存量测试构造)时不 panic,降级为错误快照。 + return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now()) + } + + now := time.Now() + + if cached, ok := f.cachedSnapshot(accountID, now); ok { + return cached + } + + // singleflight 合并同账号并发抓取;脱离调用方 ctx(仿 CN 配额服务), + // 避免某个监控的取消波及共享同一账号的其他监控。 + key := "monitor-quota:" + strconv.FormatInt(accountID, 10) + ch := f.flight.DoChan(key, func() (any, error) { + fetchCtx, cancel := context.WithTimeout(context.Background(), monitorQuotaFetchTimeout) + defer cancel() + snapshot := f.fetchUncached(fetchCtx, accountID, time.Now()) + // 失败也进短 TTL 负缓存:凭据失效/故障期间不必每次调度都打上游。 + ttl := monitorQuotaFetchCacheTTL + if !snapshot.Success { + ttl = monitorQuotaErrorCacheTTL + } + f.storeSnapshot(accountID, snapshot, time.Now().Add(ttl)) + return snapshot, nil + }) + select { + case <-ctx.Done(): + return quotaErrorSnapshot("usage", "context canceled", now) + case res := <-ch: + snapshot, ok := res.Val.(*domain.MonitorQuotaSnapshot) + if res.Err != nil || !ok || snapshot == nil { + return quotaErrorSnapshot("usage", "quota fetch failed", now) + } + return snapshot + } +} + +func (f *ChannelMonitorQuotaFetcher) cachedSnapshot(accountID int64, now time.Time) (*domain.MonitorQuotaSnapshot, bool) { + f.mu.Lock() + defer f.mu.Unlock() + entry, ok := f.cache[accountID] + if !ok || now.After(entry.expiry) { + return nil, false + } + return entry.snapshot, true +} + +func (f *ChannelMonitorQuotaFetcher) storeSnapshot(accountID int64, snapshot *domain.MonitorQuotaSnapshot, expiry time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + f.cache[accountID] = monitorQuotaCacheEntry{snapshot: snapshot, expiry: expiry} +} + +func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f == nil { + return quotaErrorSnapshot("usage", "quota fetcher is not configured", now) + } + + account, err := f.LoadAccount(ctx, accountID) + if err != nil || account == nil { + // FK ON DELETE SET NULL 后 account_id 可能为空/失效;显式报「账号未关联」, + // 推导为 degraded(配置问题,不是渠道故障)。 + slog.Warn("channel_monitor: load linked account failed", + "account_id", accountID, "error", err) + return quotaErrorSnapshot("usage", "linked account not found", now) + } + + // 账号只在路由前加载这一次;已加载的 account 直接传给数据源 + // (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount), + // 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。 + switch account.Platform { + case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek: + if account.IsCodingPlan() { + return f.fetchCNQuota(ctx, account, now) + } + return f.fetchCNBalance(ctx, account, now) + default: + return f.fetchUsage(ctx, account, now) + } +} + +// fetchUsage 海外平台:AccountUsageService.GetUsageForAccount → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchUsage(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot { + if f.usage == nil { + return quotaErrorSnapshot("usage", "usage service is not configured", now) + } + usage, err := f.usage.GetUsageForAccount(ctx, account) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + if usage == nil { + return quotaErrorSnapshot("usage", "usage service returned no data", now) + } + // openai/gemini/antigravity/grok 的失败多走「值通道」(err==nil 但错误 + // 降级在 UsageInfo 字段里),必须显式识别,否则会被误判为 operational。 + if failed, credInvalid, msg := usageFailureInfo(usage); failed { + return &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: false, + CredentialInvalid: credInvalid, + Error: truncateMessage(sanitizeErrorMessage(msg)), + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: true, + PlanLevel: usage.SubscriptionTier, + Tiers: usageQuotaTiers(usage), + FetchedAt: now, + } + if snapshot.PlanLevel == "" { + snapshot.PlanLevel = usage.SubscriptionTierRaw + } + return snapshot +} + +// usageQuotaTiers 把 UsageInfo 的各平台窗口归一为 tier 列表(无数据的窗口跳过)。 +func usageQuotaTiers(usage *UsageInfo) []domain.MonitorQuotaTier { + if usage == nil { + return nil + } + tiers := make([]domain.MonitorQuotaTier, 0, 8) + appendProgressTier(&tiers, "5h", "", usage.FiveHour) + appendProgressTier(&tiers, "7d", "", usage.SevenDay) + appendProgressTier(&tiers, "7d-sonnet", "", usage.SevenDaySonnet) + appendProgressTier(&tiers, "7d-fable", "", usage.SevenDayFable) + appendProgressTier(&tiers, "30d", "", usage.ThirtyDay) + // Gemini 多档日配额:同 Window 不同 Label。 + appendProgressTier(&tiers, "daily", "shared", usage.GeminiSharedDaily) + appendProgressTier(&tiers, "daily", "pro", usage.GeminiProDaily) + appendProgressTier(&tiers, "daily", "flash", usage.GeminiFlashDaily) + // Grok requests/tokens 两个日窗口 + 月度计费窗口。 + appendQuotaWindowTier(&tiers, "daily", "requests", usage.GrokRequestQuota) + appendQuotaWindowTier(&tiers, "daily", "tokens", usage.GrokTokenQuota) + // Antigravity per-model 总量额度,Label = 模型名(按名排序保证输出稳定)。 + for _, model := range sortedQuotaModelNames(usage.AntigravityQuota) { + q := usage.AntigravityQuota[model] + if q == nil { + continue + } + tiers = append(tiers, domain.MonitorQuotaTier{ + Window: "total", + Label: model, + UsedPercent: float64(q.Utilization), + ResetAt: q.ResetTime, + }) + } + if len(tiers) == 0 { + return nil + } + return tiers +} + +func appendProgressTier(tiers *[]domain.MonitorQuotaTier, window, label string, p *UsageProgress) { + if p == nil { + return + } + tier := domain.MonitorQuotaTier{ + Window: window, + Label: label, + UsedPercent: p.Utilization, + } + if p.ResetsAt != nil { + tier.ResetAt = p.ResetsAt.UTC().Format(time.RFC3339) + } + if p.LimitRequests > 0 { + tier.Used = float64(p.UsedRequests) + tier.Limit = float64(p.LimitRequests) + } + *tiers = append(*tiers, tier) +} + +func appendQuotaWindowTier(tiers *[]domain.MonitorQuotaTier, window, label string, q *xai.QuotaWindow) { + if q == nil || q.Limit == nil || *q.Limit <= 0 { + return + } + used := *q.Limit + if q.Remaining != nil { + used = *q.Limit - *q.Remaining + if used < 0 { + used = 0 + } + } + tier := domain.MonitorQuotaTier{ + Window: window, + Label: label, + Used: float64(used), + Limit: float64(*q.Limit), + UsedPercent: float64(used) / float64(*q.Limit) * 100, + } + if q.ResetAt != "" { + tier.ResetAt = q.ResetAt + } else if q.ResetUnix != nil && *q.ResetUnix > 0 { + tier.ResetAt = time.Unix(*q.ResetUnix, 0).UTC().Format(time.RFC3339) + } + *tiers = append(*tiers, tier) +} + +func sortedQuotaModelNames(quotas map[string]*AntigravityModelQuota) []string { + names := make([]string, 0, len(quotas)) + for name := range quotas { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// fetchCNQuota 国产 coding plan:CNProviderQuotaService.QueryUsageForAccount → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot { + if f.cnQuota == nil { + return quotaErrorSnapshot("cn_quota", "cn quota service is not configured", now) + } + result, err := f.cnQuota.QueryUsageForAccount(ctx, account) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "cn_quota", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "cn_quota", + Success: result.Success, + PlanLevel: result.PlanLevel, + Error: result.Error, + FetchedAt: now, + } + // 只有 401/403 判凭据失效(与 fetchCNBalance 口径一致):CN quota 服务的 + // CredentialValid 仅在成功路径置 true,若按 `!Success && !CredentialValid` + // 推导,500/429/智谱业务错误全会被误判为 failed。 + if !result.Success && (result.StatusCode == 401 || result.StatusCode == 403) { + snapshot.CredentialInvalid = true + } + if len(result.Tiers) > 0 { + snapshot.Tiers = make([]domain.MonitorQuotaTier, 0, len(result.Tiers)) + for _, t := range result.Tiers { + snapshot.Tiers = append(snapshot.Tiers, domain.MonitorQuotaTier{ + Window: t.Window, + UsedPercent: t.UsedPercent, + ResetAt: t.ResetAt, + }) + } + } + if !snapshot.Success { + snapshot.Error = firstNonEmpty(snapshot.Error, "cn quota probe failed") + } + return snapshot +} + +// fetchCNBalance 国产 payg:CNProviderBalanceService.QueryBalanceForAccount → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot { + if f.cnBalance == nil { + return quotaErrorSnapshot("cn_balance", "cn balance service is not configured", now) + } + result, err := f.cnBalance.QueryBalanceForAccount(ctx, account) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "cn_balance", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "cn_balance", + Success: result.Success, + Currency: result.Currency, + Error: result.Error, + FetchedAt: now, + } + if result.Success { + balance := result.Balance + snapshot.Balance = &balance + // 与账号停调(checkOne)同口径:上游标记不可用或全部币种低于阈值 + // 才告警,任一币种达标即健康(余额 5 元/阈值 10 元的账号调度器已 + // 停调,监控不能仍绿灯)。 + snapshot.BalanceLow = !result.Available || allCNBalancesBelowThreshold(result, f.balanceThreshold) + } else if result.StatusCode == 401 || result.StatusCode == 403 { + snapshot.CredentialInvalid = true + } + if len(result.Balances) > 0 { + snapshot.Balances = make([]domain.MonitorBalance, 0, len(result.Balances)) + for _, b := range result.Balances { + snapshot.Balances = append(snapshot.Balances, domain.MonitorBalance{ + Currency: b.Currency, + Balance: b.Balance, + }) + } + } + if !snapshot.Success { + snapshot.Error = firstNonEmpty(snapshot.Error, "cn balance probe failed") + } + return snapshot +} + +// quotaErrorSnapshot 构造统一错误快照。 +func quotaErrorSnapshot(source, message string, now time.Time) *domain.MonitorQuotaSnapshot { + return &domain.MonitorQuotaSnapshot{ + Source: source, + Success: false, + Error: truncateMessage(sanitizeErrorMessage(message)), + FetchedAt: now, + } +} + +// isCredentialErrorMessage 上游 401/403 鉴权失败的启发式识别 +// (海外 GetUsage 的错误没有结构化状态码,只能看文本)。 +func isCredentialErrorMessage(msg string) bool { + msg = strings.ToLower(msg) + return strings.Contains(msg, "401") || + strings.Contains(msg, "403") || + strings.Contains(msg, "unauthorized") || + strings.Contains(msg, "forbidden") || + strings.Contains(msg, "invalid_api_key") || + strings.Contains(msg, "authentication") +} + +// usageFailureInfo 识别 GetUsage 经「值通道」返回的失败:antigravity/grok +// 等平台 err==nil 但把错误降级在 UsageInfo 字段里(Error/ErrorCode/状态标记)。 +// 返回 failed=false 表示可用;credentialInvalid 表示凭据失效(401/403 语义, +// 推导为 failed 状态);msg 为失败摘要。 +// +// grok 的 ErrorCode=quota_unknown 是「尚未观测到计费快照/限流头」的已知未知态, +// 不是失败(严格按 ErrorCode 判会把健康 grok 账号永久判 error),显式豁免。 +func usageFailureInfo(usage *UsageInfo) (failed, credentialInvalid bool, msg string) { + if usage == nil { + return false, false, "" + } + if usage.ErrorCode == "quota_unknown" { + return false, false, "" + } + failed = usage.Error != "" || usage.NeedsReauth || usage.IsBanned || + usage.IsForbidden || usage.ErrorCode != "" + if !failed { + return false, false, "" + } + credentialInvalid = usage.NeedsReauth || usage.IsBanned || usage.IsForbidden || + usage.ErrorCode == errorCodeUnauthenticated || usage.ErrorCode == errorCodeForbidden + msg = firstNonEmpty(usage.Error, usage.ForbiddenReason, usage.ErrorCode, "usage fetch failed") + return failed, credentialInvalid, msg +} + +// deriveQuotaCheckResult 把配额快照推导为检测状态(复用既有 status 枚举, +// 时间线/可用率机制自动生效): +// - 查询成功且无告警 → operational +// - 任一窗口使用率 >= 阈值或余额低于阈值/不可用 → degraded +// - 账号未关联(配置问题) → degraded +// - 凭据失效(401/403) → failed +// - 网络/解析等其他错误 → error +func deriveQuotaCheckResult(snapshot *domain.MonitorQuotaSnapshot, model string, checkedAt time.Time) *CheckResult { + res := &CheckResult{Model: model, CheckedAt: checkedAt} + if snapshot == nil { + res.Status = MonitorStatusError + res.Message = "quota snapshot missing" + return res + } + + switch { + case !snapshot.Success && snapshot.CredentialInvalid: + res.Status = MonitorStatusFailed + res.Message = snapshot.Error + case !snapshot.Success && strings.Contains(snapshot.Error, "linked account not found"): + res.Status = MonitorStatusDegraded + res.Message = snapshot.Error + case !snapshot.Success: + res.Status = MonitorStatusError + res.Message = snapshot.Error + default: + if hint := quotaDegradedHint(snapshot); hint != "" { + res.Status = MonitorStatusDegraded + res.Message = hint + } else { + res.Status = MonitorStatusOperational + } + } + return res +} + +// quotaDegradedHint 生成 degraded 的 message(指出触发告警的窗口/余额); +// 空串表示无告警。 +func quotaDegradedHint(snapshot *domain.MonitorQuotaSnapshot) string { + for _, tier := range snapshot.Tiers { + if tier.UsedPercent >= monitorQuotaDegradedUsedPercent { + name := tier.Window + if tier.Label != "" { + name = tier.Label + "/" + tier.Window + } + return fmt.Sprintf("quota high: %s at %s%%", name, strconv.FormatFloat(tier.UsedPercent, 'f', 1, 64)) + } + } + if snapshot.BalanceLow { + if snapshot.Balance != nil { + return fmt.Sprintf("balance low: %s %s", strconv.FormatFloat(*snapshot.Balance, 'f', -1, 64), firstNonEmpty(snapshot.Currency, "?")) + } + return fmt.Sprintf("balance low (%s)", firstNonEmpty(snapshot.Currency, "?")) + } + return "" +} diff --git a/backend/internal/service/channel_monitor_quota_fetcher_test.go b/backend/internal/service/channel_monitor_quota_fetcher_test.go new file mode 100644 index 0000000000..6c5b06559b --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_fetcher_test.go @@ -0,0 +1,681 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +// --- fetcher 依赖 stub --- + +type stubMonitorUsageSource struct { + usage *UsageInfo + err error + // block 非 nil 时 GetUsageForAccount 阻塞在该 channel 上,用于并发/singleflight 测试。 + block chan struct{} + + mu sync.Mutex + calls int + lastCtx context.Context + lastAccount *Account +} + +func (s *stubMonitorUsageSource) GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) { + s.mu.Lock() + s.calls++ + s.lastCtx = ctx + s.lastAccount = account + s.mu.Unlock() + if s.block != nil { + <-s.block + } + return s.usage, s.err +} + +func (s *stubMonitorUsageSource) getCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +func (s *stubMonitorUsageSource) getLastAccount() *Account { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastAccount +} + +type stubMonitorCNQuotaSource struct { + result *CNProviderQuotaProbeResult + err error + calls int + lastAccount *Account +} + +func (s *stubMonitorCNQuotaSource) QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { + s.calls++ + s.lastAccount = account + return s.result, s.err +} + +type stubMonitorCNBalanceSource struct { + result *CNProviderBalanceResult + err error + calls int + lastAccount *Account +} + +func (s *stubMonitorCNBalanceSource) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { + s.calls++ + s.lastAccount = account + return s.result, s.err +} + +type stubMonitorAccountSource struct { + accounts map[int64]*Account + err error + calls int +} + +func (s *stubMonitorAccountSource) GetByID(ctx context.Context, id int64) (*Account, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + return s.accounts[id], nil +} + +func newQuotaFetcherTestSetup(t *testing.T) (*ChannelMonitorQuotaFetcher, *stubMonitorUsageSource, *stubMonitorCNQuotaSource, *stubMonitorCNBalanceSource, *stubMonitorAccountSource) { + t.Helper() + usage := &stubMonitorUsageSource{} + cnQuota := &stubMonitorCNQuotaSource{} + cnBalance := &stubMonitorCNBalanceSource{} + accounts := &stubMonitorAccountSource{accounts: make(map[int64]*Account)} + fetcher := &ChannelMonitorQuotaFetcher{ + usage: usage, + cnQuota: cnQuota, + cnBalance: cnBalance, + accounts: accounts, + balanceThreshold: monitorBalanceThreshold(nil), + cache: make(map[int64]monitorQuotaCacheEntry), + } + return fetcher, usage, cnQuota, cnBalance, accounts +} + +// --- 分派 --- + +func TestQuotaFetcher_OverseasAccountUsesUsageService(t *testing.T) { + fetcher, usage, _, cnQuota, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[7] = &Account{ID: 7, Platform: domain.PlatformAnthropic} + resets := time.Now().Add(2 * time.Hour).UTC() + usage.usage = &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 42.5, UsedRequests: 17, LimitRequests: 40, ResetsAt: &resets}, + SevenDay: &UsageProgress{Utilization: 10}, + SubscriptionTier: "PRO", + } + + snapshot := fetcher.Fetch(context.Background(), 7) + + require.True(t, snapshot.Success) + require.Equal(t, "usage", snapshot.Source) + require.Equal(t, "PRO", snapshot.PlanLevel) + require.False(t, snapshot.CredentialInvalid) + require.Empty(t, snapshot.Error) + require.Len(t, snapshot.Tiers, 2) + + fiveHour := snapshot.Tiers[0] + require.Equal(t, "5h", fiveHour.Window) + require.Empty(t, fiveHour.Label) + require.InDelta(t, 42.5, fiveHour.UsedPercent, 0.001) + require.Equal(t, float64(17), fiveHour.Used) + require.Equal(t, float64(40), fiveHour.Limit) + require.NotEmpty(t, fiveHour.ResetAt) + + require.Equal(t, "7d", snapshot.Tiers[1].Window) + require.Equal(t, 1, usage.getCalls()) + require.Equal(t, 0, cnQuota.calls) +} + +func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) { + fetcher, _, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[9] = &Account{ + ID: 9, + Platform: domain.PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + PlanLevel: "", + Tiers: []CNQuotaTier{ + {Window: "5h", UsedPercent: 33.3, ResetAt: "2026-08-18T06:00:00Z"}, + {Window: "weekly", UsedPercent: 12}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 9) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_quota", snapshot.Source) + require.Len(t, snapshot.Tiers, 2) + require.Equal(t, "5h", snapshot.Tiers[0].Window) + require.InDelta(t, 33.3, snapshot.Tiers[0].UsedPercent, 0.001) + require.Equal(t, "weekly", snapshot.Tiers[1].Window) + require.Equal(t, 1, cnQuota.calls) + require.Equal(t, 0, cnBalance.calls) +} + +func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[11] = &Account{ + ID: 11, + Platform: domain.PlatformDeepseek, + Credentials: map[string]any{"account_mode": AccountModePayG}, + } + cnBalance.result = &CNProviderBalanceResult{ + Success: true, + Available: true, + Balance: 12.34, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 12.34}, + {Currency: "USD", Balance: 1.5}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 11) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_balance", snapshot.Source) + require.NotNil(t, snapshot.Balance) + require.InDelta(t, 12.34, *snapshot.Balance, 0.001) + require.Equal(t, "CNY", snapshot.Currency) + require.Len(t, snapshot.Balances, 2) + require.Equal(t, "USD", snapshot.Balances[1].Currency) + require.False(t, snapshot.BalanceLow) + require.Empty(t, snapshot.Error) +} + +// P2-6:fetchUncached 只 GetByID 一次,已加载的 account 指针直传数据源, +// 三条路由都不能让下游重载账号。 +func TestQuotaFetcher_LoadsAccountOnceAndPassesItThrough(t *testing.T) { + t.Run("overseas usage", func(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + acc := &Account{ID: 21, Platform: domain.PlatformAnthropic} + accounts.accounts[21] = acc + usage.usage = &UsageInfo{} + + fetcher.Fetch(context.Background(), 21) + + require.Equal(t, 1, accounts.calls) + require.Same(t, acc, usage.getLastAccount()) + require.Equal(t, 1, usage.getCalls()) + }) + + t.Run("cn coding plan", func(t *testing.T) { + fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) + acc := &Account{ID: 22, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}} + accounts.accounts[22] = acc + cnQuota.result = &CNProviderQuotaProbeResult{Success: true} + + fetcher.Fetch(context.Background(), 22) + + require.Equal(t, 1, accounts.calls) + require.Same(t, acc, cnQuota.lastAccount) + require.Equal(t, 1, cnQuota.calls) + }) + + t.Run("cn payg", func(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + acc := &Account{ID: 23, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModePayG}} + accounts.accounts[23] = acc + cnBalance.result = &CNProviderBalanceResult{Success: true, Available: true, Balance: 1, Currency: "CNY"} + + fetcher.Fetch(context.Background(), 23) + + require.Equal(t, 1, accounts.calls) + require.Same(t, acc, cnBalance.lastAccount) + require.Equal(t, 1, cnBalance.calls) + }) +} + +// --- 失败路径(Fetch 永不返回 error) --- + +func TestQuotaFetcher_AccountMissingYieldsLinkedAccountSnapshot(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.err = errors.New("not found") + + snapshot := fetcher.Fetch(context.Background(), 404) + + require.False(t, snapshot.Success) + require.Equal(t, "linked account not found", snapshot.Error) + require.Equal(t, 0, usage.getCalls()) // 未走到数据源 +} + +func TestQuotaFetcher_UsageAuthErrorMarksCredentialInvalid(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformOpenAI} + usage.err = errors.New("API returned 401: unauthorized") + + snapshot := fetcher.Fetch(context.Background(), 3) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "401") +} + +// 值通道失败:antigravity/grok 等平台 err==nil 但错误降级在 UsageInfo 字段里, +// 必须识别为失败快照,否则会被误判为 operational。 +func TestQuotaFetcher_UsageValueChannelFailureYieldsFailureSnapshot(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + + // 凭据失效(401 语义)→ failed。 + accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformAnthropic} + usage.usage = &UsageInfo{Error: "usage API error: HTTP 401", ErrorCode: errorCodeUnauthenticated, NeedsReauth: true} + snapshot := fetcher.Fetch(context.Background(), 3) + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "401") + require.Equal(t, MonitorStatusFailed, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + + // 限流等非凭据失败 → error(而非 operational)。 + accounts.accounts[13] = &Account{ID: 13, Platform: domain.PlatformAnthropic} + usage.usage = &UsageInfo{Error: "usage API error: HTTP 429", ErrorCode: errorCodeRateLimited} + snapshot = fetcher.Fetch(context.Background(), 13) + require.False(t, snapshot.Success) + require.False(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "429") + require.Equal(t, MonitorStatusError, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + + // grok 已知未知态(尚未观测到计费/限流头)不算失败。 + accounts.accounts[14] = &Account{ID: 14, Platform: domain.PlatformGrok} + usage.usage = &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"} + snapshot = fetcher.Fetch(context.Background(), 14) + require.True(t, snapshot.Success) + require.Empty(t, snapshot.Error) + require.Empty(t, snapshot.Tiers) + require.Equal(t, MonitorStatusOperational, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) +} + +func TestUsageFailureInfo_ClassificationMatrix(t *testing.T) { + cases := []struct { + name string + usage *UsageInfo + failed bool + credentialInvalid bool + msg string + }{ + {name: "nil usage", usage: nil}, + {name: "healthy empty", usage: &UsageInfo{}}, + {name: "error text only", usage: &UsageInfo{Error: "boom"}, failed: true, msg: "boom"}, + {name: "needs reauth", usage: &UsageInfo{NeedsReauth: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"}, + {name: "banned", usage: &UsageInfo{IsBanned: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"}, + {name: "forbidden with reason", usage: &UsageInfo{IsForbidden: true, ForbiddenReason: "usage limited"}, failed: true, credentialInvalid: true, msg: "usage limited"}, + {name: "error code unauthenticated", usage: &UsageInfo{ErrorCode: errorCodeUnauthenticated}, failed: true, credentialInvalid: true, msg: errorCodeUnauthenticated}, + {name: "error code forbidden", usage: &UsageInfo{ErrorCode: errorCodeForbidden}, failed: true, credentialInvalid: true, msg: errorCodeForbidden}, + {name: "error code rate limited", usage: &UsageInfo{ErrorCode: errorCodeRateLimited}, failed: true, msg: errorCodeRateLimited}, + {name: "error code network error", usage: &UsageInfo{ErrorCode: errorCodeNetworkError}, failed: true, msg: errorCodeNetworkError}, + {name: "grok quota unknown exempted", usage: &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + failed, credentialInvalid, msg := usageFailureInfo(tc.usage) + require.Equal(t, tc.failed, failed) + require.Equal(t, tc.credentialInvalid, credentialInvalid) + if tc.msg != "" { + require.Equal(t, tc.msg, msg) + } + }) + } +} + +// 凭据失效只认 401/403(与 fetchCNBalance 口径一致):CN quota 服务的 +// CredentialValid 仅成功路径置 true,500/429/智谱业务错误须推导为 error 而非 failed。 +func TestQuotaFetcher_CNQuotaCredentialInvalidByStatusCode(t *testing.T) { + cases := []struct { + name string + accountID int64 + statusCode int + credentialBad bool + expectedStatus string + }{ + {name: "401 unauthorized", accountID: 5, statusCode: 401, credentialBad: true, expectedStatus: MonitorStatusFailed}, + {name: "403 forbidden", accountID: 15, statusCode: 403, credentialBad: true, expectedStatus: MonitorStatusFailed}, + {name: "500 server error", accountID: 16, statusCode: 500, expectedStatus: MonitorStatusError}, + {name: "429 rate limited", accountID: 17, statusCode: 429, expectedStatus: MonitorStatusError}, + // 智谱 2xx 但业务级失败:StatusCode=200,非凭据问题。 + {name: "200 business error", accountID: 18, statusCode: 200, expectedStatus: MonitorStatusError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[tc.accountID] = &Account{ + ID: tc.accountID, + Platform: domain.PlatformZhipu, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: false, + StatusCode: tc.statusCode, + Error: "api key expired", + } + + snapshot := fetcher.Fetch(context.Background(), tc.accountID) + + require.False(t, snapshot.Success) + require.Equal(t, tc.credentialBad, snapshot.CredentialInvalid) + require.Equal(t, tc.expectedStatus, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + }) + } +} + +func TestQuotaFetcher_CNBalanceHTTP403MarksCredentialInvalid(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[6] = &Account{ID: 6, Platform: domain.PlatformKimi} + cnBalance.result = &CNProviderBalanceResult{Success: false, StatusCode: 403, Error: "forbidden"} + + snapshot := fetcher.Fetch(context.Background(), 6) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) +} + +// 余额告警口径与账号停调(CNProviderBalanceCheckService.checkOne)一致: +// 上游标记不可用或全部币种低于阈值 → BalanceLow → degraded;任一币种达标即健康。 +func TestQuotaFetcher_CNBalanceLowMarksDegraded(t *testing.T) { + cases := []struct { + name string + accountID int64 + result *CNProviderBalanceResult + balanceLow bool + wantStatus string + wantMessage string + }{ + { + // 审查例:余额 5/阈值 10 的账号调度器已停调,监控不能仍绿灯。 + name: "balance below threshold", + accountID: 21, + result: &CNProviderBalanceResult{Success: true, Available: true, Balance: 5, Currency: "CNY"}, + balanceLow: true, + wantStatus: MonitorStatusDegraded, wantMessage: "balance low: 5 CNY", + }, + { + name: "upstream marked unavailable", + accountID: 22, + result: &CNProviderBalanceResult{Success: true, Available: false, Balance: 20, Currency: "CNY"}, + balanceLow: true, + wantStatus: MonitorStatusDegraded, wantMessage: "balance low: 20 CNY", + }, + { + // deepseek 双币种:任一币种(USD 20)达标即健康。 + name: "any currency above threshold is healthy", + accountID: 23, + result: &CNProviderBalanceResult{ + Success: true, Available: true, Balance: 5, Currency: "CNY", + Balances: []CNProviderBalanceEntry{{Currency: "CNY", Balance: 5}, {Currency: "USD", Balance: 20}}, + }, + wantStatus: MonitorStatusOperational, + }, + { + name: "single currency above threshold", + accountID: 24, + result: &CNProviderBalanceResult{Success: true, Available: true, Balance: 20, Currency: "CNY"}, + wantStatus: MonitorStatusOperational, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + fetcher.balanceThreshold = 10 + accounts.accounts[tc.accountID] = &Account{ + ID: tc.accountID, + Platform: domain.PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModePayG}, + } + cnBalance.result = tc.result + + snapshot := fetcher.Fetch(context.Background(), tc.accountID) + + require.True(t, snapshot.Success) + require.Equal(t, tc.balanceLow, snapshot.BalanceLow) + res := deriveQuotaCheckResult(snapshot, "quota", time.Now()) + require.Equal(t, tc.wantStatus, res.Status) + if tc.wantMessage != "" { + require.Contains(t, res.Message, tc.wantMessage) + } else { + require.Empty(t, res.Message) + } + }) + } +} + +func TestNewChannelMonitorQuotaFetcher_ThresholdFromConfig(t *testing.T) { + require.InDelta(t, 0.5, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, nil).balanceThreshold, 0.0001) + + cfg10 := &config.Config{Gateway: config.GatewayConfig{CNProviders: config.GatewayCNProvidersConfig{BalanceThreshold: 10}}} + require.InDelta(t, 10, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, cfg10).balanceThreshold, 0.0001) + + // 非正值(含显式 0)回退默认,避免 0 阈值下「余额=0 也不告警」。 + cfg0 := &config.Config{Gateway: config.GatewayConfig{CNProviders: config.GatewayCNProvidersConfig{BalanceThreshold: 0}}} + require.InDelta(t, 0.5, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, cfg0).balanceThreshold, 0.0001) +} + +func TestQuotaFetcher_NilDependenciesProduceErrorSnapshots(t *testing.T) { + // fetcher 本体为 nil:直接降级为错误快照,不 panic。 + var nilFetcher *ChannelMonitorQuotaFetcher + snapshot := nilFetcher.Fetch(context.Background(), 1) + require.False(t, snapshot.Success) + require.Equal(t, "quota fetcher is not configured", snapshot.Error) + + // 数据源缺失:账号能加载,但对应服务未注入。 + fetcher, _, _, _, accounts := newQuotaFetcherTestSetup(t) + fetcher.usage = nil + accounts.accounts[2] = &Account{ID: 2, Platform: domain.PlatformOpenAI} + snapshot = fetcher.Fetch(context.Background(), 2) + require.False(t, snapshot.Success) + require.Contains(t, snapshot.Error, "not configured") +} + +// --- TTL 缓存 --- + +func TestQuotaFetcher_CachesSuccessSnapshotPerAccount(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[8] = &Account{ID: 8, Platform: domain.PlatformOpenAI} + usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}} + + for i := 0; i < 3; i++ { + snapshot := fetcher.Fetch(context.Background(), 8) + require.True(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls(), "success snapshots should be served from cache") + + // 缓存过期后重新拉取。 + fetcher.mu.Lock() + entry := fetcher.cache[8] + entry.expiry = time.Now().Add(-time.Second) + fetcher.cache[8] = entry + fetcher.mu.Unlock() + + _ = fetcher.Fetch(context.Background(), 8) + require.Equal(t, 2, usage.getCalls()) +} + +func TestQuotaFetcher_CachesFailureSnapshotWithShortTTL(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[4] = &Account{ID: 4, Platform: domain.PlatformOpenAI} + usage.err = errors.New("boom") + + for i := 0; i < 2; i++ { + snapshot := fetcher.Fetch(context.Background(), 4) + require.False(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls(), "failure snapshots should be served from the short negative cache") + + // 失败快照的 TTL 是负缓存时长(而非成功 TTL)。 + fetcher.mu.Lock() + entry := fetcher.cache[4] + require.WithinDuration(t, entry.snapshot.FetchedAt.Add(monitorQuotaErrorCacheTTL), entry.expiry, time.Second) + entry.expiry = time.Now().Add(-time.Second) + fetcher.cache[4] = entry + fetcher.mu.Unlock() + + _ = fetcher.Fetch(context.Background(), 4) + require.Equal(t, 2, usage.getCalls(), "expired negative cache should refetch") +} + +func TestQuotaFetcher_ConcurrentFetchesShareSingleFlight(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[12] = &Account{ID: 12, Platform: domain.PlatformOpenAI} + usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}} + usage.block = make(chan struct{}) + + var wg sync.WaitGroup + snapshots := make([]*domain.MonitorQuotaSnapshot, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + snapshots[idx] = fetcher.Fetch(context.Background(), 12) + }(i) + } + + // 上游被 block 卡住时,5 个并发 Fetch 应只产生 1 次真实查询。 + require.Eventually(t, func() bool { return usage.getCalls() == 1 }, + 5*time.Second, 10*time.Millisecond) + close(usage.block) + wg.Wait() + + for _, snapshot := range snapshots { + require.NotNil(t, snapshot) + require.True(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls()) + + // 成功快照已缓存:再取一次仍不打上游。 + _ = fetcher.Fetch(context.Background(), 12) + require.Equal(t, 1, usage.getCalls()) +} + +// --- UsageInfo → tiers 归一 --- + +func TestUsageQuotaTiers_MapsAllWindowKinds(t *testing.T) { + limit := int64(1000) + remaining := int64(400) + resetUnix := int64(1777283883) + usage := &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 50}, + SevenDay: &UsageProgress{Utilization: 60}, + SevenDaySonnet: &UsageProgress{Utilization: 70}, + SevenDayFable: &UsageProgress{Utilization: 80}, + ThirtyDay: &UsageProgress{Utilization: 20}, + GeminiSharedDaily: &UsageProgress{Utilization: 11}, + GeminiProDaily: &UsageProgress{Utilization: 22}, + GeminiFlashDaily: &UsageProgress{Utilization: 33}, + GrokRequestQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetUnix: &resetUnix}, + GrokTokenQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetAt: "2026-08-19T00:00:00Z"}, + AntigravityQuota: map[string]*AntigravityModelQuota{ + "gemini-3-pro": {Utilization: 45}, + "gemini-3-flash": {Utilization: 55}, + }, + } + + tiers := usageQuotaTiers(usage) + + // 5h/7d/7d-sonnet/7d-fable/30d + gemini×3 + grok×2 + antigravity×2 + require.Len(t, tiers, 12) + + byKey := make(map[string]domain.MonitorQuotaTier, len(tiers)) + for _, tier := range tiers { + key := tier.Window + if tier.Label != "" { + key = tier.Window + "/" + tier.Label + } + byKey[key] = tier + } + + require.Contains(t, byKey, "5h") + require.Contains(t, byKey, "7d") + require.Contains(t, byKey, "7d-sonnet") + require.Contains(t, byKey, "7d-fable") + require.Contains(t, byKey, "30d") + require.Contains(t, byKey, "daily/shared") + require.Contains(t, byKey, "daily/pro") + require.Contains(t, byKey, "daily/flash") + require.Contains(t, byKey, "daily/requests") + require.Contains(t, byKey, "daily/tokens") + require.Contains(t, byKey, "total/gemini-3-pro") + require.Contains(t, byKey, "total/gemini-3-flash") + + // grok requests 窗口:used = limit - remaining,百分比 60%。 + requests := byKey["daily/requests"] + require.Equal(t, float64(600), requests.Used) + require.Equal(t, float64(1000), requests.Limit) + require.InDelta(t, 60.0, requests.UsedPercent, 0.001) + require.NotEmpty(t, requests.ResetAt, "ResetUnix should fall back to RFC3339") + + tokens := byKey["daily/tokens"] + require.Equal(t, "2026-08-19T00:00:00Z", tokens.ResetAt) +} + +func TestUsageQuotaTiers_NilAndEmptyInputs(t *testing.T) { + require.Nil(t, usageQuotaTiers(nil)) + require.Nil(t, usageQuotaTiers(&UsageInfo{})) + + // Grok 窗口 limit<=0 时跳过,避免除零。 + var zero int64 + tiers := usageQuotaTiers(&UsageInfo{ + GrokRequestQuota: &xai.QuotaWindow{Limit: &zero, Remaining: &zero}, + }) + require.Nil(t, tiers) +} + +// --- 状态推导 --- + +func TestDeriveQuotaCheckResult_StatusMatrix(t *testing.T) { + now := time.Now() + + healthy := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{{Window: "5h", UsedPercent: 40}}} + res := deriveQuotaCheckResult(healthy, "quota", now) + require.Equal(t, MonitorStatusOperational, res.Status) + require.Equal(t, "quota", res.Model) + require.Empty(t, res.Message) + + highUsage := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{ + {Window: "5h", UsedPercent: 30}, + {Window: "daily", Label: "pro", UsedPercent: 95}, + }} + res = deriveQuotaCheckResult(highUsage, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + require.Contains(t, res.Message, "pro/daily") + require.Contains(t, res.Message, "95.0%") + + balance := -0.5 + lowBalance := &domain.MonitorQuotaSnapshot{Success: true, BalanceLow: true, Balance: &balance, Currency: "CNY"} + res = deriveQuotaCheckResult(lowBalance, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + require.Contains(t, res.Message, "balance low") + + invalid := &domain.MonitorQuotaSnapshot{Success: false, CredentialInvalid: true, Error: "401 unauthorized"} + res = deriveQuotaCheckResult(invalid, "quota", now) + require.Equal(t, MonitorStatusFailed, res.Status) + + unlinked := &domain.MonitorQuotaSnapshot{Success: false, Error: "linked account not found"} + res = deriveQuotaCheckResult(unlinked, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + + other := &domain.MonitorQuotaSnapshot{Success: false, Error: "connection refused"} + res = deriveQuotaCheckResult(other, "quota", now) + require.Equal(t, MonitorStatusError, res.Status) + require.Equal(t, "connection refused", res.Message) + + res = deriveQuotaCheckResult(nil, "quota", now) + require.Equal(t, MonitorStatusError, res.Status) +} diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go new file mode 100644 index 0000000000..7309d2cf2a --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -0,0 +1,624 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/stretchr/testify/require" +) + +// --- repo / fetcher 装配 --- + +// quotaModeRepoStub 记录 RunCheck 落库行为(历史行 + MarkChecked)。 +type quotaModeRepoStub struct { + ChannelMonitorRepository + monitor *ChannelMonitor + history []*ChannelMonitorHistoryRow + markedIDs []int64 + updated []*ChannelMonitor +} + +func (r *quotaModeRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) { + if r.monitor == nil || r.monitor.ID != id { + return nil, ErrChannelMonitorNotFound + } + clone := *r.monitor + return &clone, nil +} + +func (r *quotaModeRepoStub) InsertHistoryBatch(_ context.Context, rows []*ChannelMonitorHistoryRow) error { + r.history = append(r.history, rows...) + return nil +} + +func (r *quotaModeRepoStub) MarkChecked(_ context.Context, id int64, _ time.Time) error { + r.markedIDs = append(r.markedIDs, id) + return nil +} + +func (r *quotaModeRepoStub) Update(_ context.Context, m *ChannelMonitor) error { + clone := *m + r.updated = append(r.updated, &clone) + return nil +} + +// newQuotaModeService 构造启用 V1 探活的 service(复用 retirement/duplicate 测试的 stub)。 +func newQuotaModeService(repo *quotaModeRepoStub) *ChannelMonitorService { + svc := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{ + Enabled: true, + Mode: ChannelMonitorModeV1, + }}) + return svc +} + +func newQuotaModeFetcher(accounts map[int64]*Account, usage *stubMonitorUsageSource) *ChannelMonitorQuotaFetcher { + if accounts == nil { + accounts = make(map[int64]*Account) + } + if usage == nil { + usage = &stubMonitorUsageSource{} + } + return &ChannelMonitorQuotaFetcher{ + usage: usage, + accounts: &stubMonitorAccountSource{accounts: accounts}, + cache: make(map[int64]monitorQuotaCacheEntry), + } +} + +// --- RunCheck 分派 --- + +func TestRunCheck_QuotaModeProducesSingleQuotaResult(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 1, + Name: "kimi-quota", + Provider: MonitorProviderKimi, + APIMode: MonitorAPIModeChatCompletions, + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: int64Ptr(9), + }} + svc := newQuotaModeService(repo) + fetcher := newQuotaModeFetcher(map[int64]*Account{ + 9: {ID: 9, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil) + fetcher.cnQuota = &stubMonitorCNQuotaSource{result: &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + Tiers: []CNQuotaTier{{Window: "5h", UsedPercent: 30}}, + }} + svc.SetQuotaFetcher(fetcher) + + results, err := svc.RunCheck(context.Background(), 1) + require.NoError(t, err) + require.Len(t, results, 1) + + res := results[0] + require.Equal(t, "quota", res.Model) + require.Equal(t, MonitorStatusOperational, res.Status) + require.Nil(t, res.LatencyMs) + require.Nil(t, res.PingLatencyMs) + require.NotNil(t, res.Quota) + require.True(t, res.Quota.Success) + require.Equal(t, "cn_quota", res.Quota.Source) + + // 历史行携带配额快照,并推进 last_checked_at。 + require.Len(t, repo.history, 1) + require.Equal(t, "quota", repo.history[0].Model) + require.NotNil(t, repo.history[0].Quota) + require.Equal(t, []int64{1}, repo.markedIDs) +} + +func TestRunCheck_QuotaModeUnlinkedAccountDegrades(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 2, + Provider: MonitorProviderDeepseek, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: "", + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: nil, // FK ON DELETE SET NULL 后的形态 + }} + svc := newQuotaModeService(repo) + svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) + + results, err := svc.RunCheck(context.Background(), 2) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusDegraded, results[0].Status) + require.Contains(t, results[0].Message, "linked account not found") + require.False(t, results[0].Quota.Success) +} + +func TestRunCheck_QuotaModeNilFetcherFailsClosed(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 3, + Provider: MonitorProviderZhipu, + APIMode: MonitorAPIModeChatCompletions, + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: int64Ptr(5), + }} + svc := newQuotaModeService(repo) // 不注入 fetcher + + results, err := svc.RunCheck(context.Background(), 3) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusError, results[0].Status) + require.Contains(t, results[0].Message, "not configured") +} + +func TestRunCheck_QuotaProbeAttachesSnapshotToPrimaryRowOnly(t *testing.T) { + h := &openAICaptureHandler{} + endpoint := setupFakeOpenAI(t, h) + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 4, + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: endpoint, + APIKey: "OLD:sk-openai", + PrimaryModel: "gpt-test", + ExtraModels: []string{"gpt-extra"}, + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuotaProbe, + AccountID: int64Ptr(12), + }} + svc := newQuotaModeService(repo) + usage := &stubMonitorUsageSource{usage: &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 20}, + }} + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 12: {ID: 12, Platform: domain.PlatformOpenAI}, + }, usage)) + + results, err := svc.RunCheck(context.Background(), 4) + require.NoError(t, err) + require.Len(t, results, 2) + + // 探活状态为准,配额只挂主模型行。 + require.Equal(t, MonitorStatusOperational, results[0].Status) + require.NotNil(t, results[0].Quota) + require.True(t, results[0].Quota.Success) + require.Equal(t, "usage", results[0].Quota.Source) + require.Nil(t, results[1].Quota, "extra model rows must not carry quota") + + // 历史落库时同样只有主模型行带快照。 + require.Len(t, repo.history, 2) + require.NotNil(t, repo.history[0].Quota) + require.Equal(t, "gpt-test", repo.history[0].Model) + require.Nil(t, repo.history[1].Quota) +} + +func TestRunCheck_QuotaProbeQuotaFailureKeepsProbeStatus(t *testing.T) { + h := &openAICaptureHandler{} + endpoint := setupFakeOpenAI(t, h) + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 5, + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: endpoint, + APIKey: "OLD:sk-openai", + PrimaryModel: "gpt-test", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuotaProbe, + AccountID: nil, // 配额侧失效 + }} + svc := newQuotaModeService(repo) + svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) + + results, err := svc.RunCheck(context.Background(), 5) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusOperational, results[0].Status, "quota failure must not flip probe status") + require.False(t, results[0].Quota.Success) +} + +// --- attachQuotaSnapshot 细节 --- + +func TestAttachQuotaSnapshot_NoteOnlyWhenProbeMessageEmpty(t *testing.T) { + results := []*CheckResult{ + {Model: "primary", Status: MonitorStatusOperational, Message: "challenge passed"}, + {Model: "extra"}, + } + failed := &domain.MonitorQuotaSnapshot{Success: false, Error: "boom"} + + attachQuotaSnapshot(results, failed) + + require.Equal(t, "challenge passed", results[0].Message, "existing probe message wins") + require.Equal(t, failed, results[0].Quota) + require.Nil(t, results[1].Quota) + + quiet := []*CheckResult{{Model: "primary", Status: MonitorStatusOperational}} + attachQuotaSnapshot(quiet, failed) + require.Contains(t, quiet[0].Message, "quota fetch failed: boom") + + attachQuotaSnapshot(nil, failed) // 空结果不 panic + attachQuotaSnapshot(results, nil) // 空快照不动结果 +} + +// --- 校验矩阵 --- + +func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { + accountID := int64(9) + + cases := []struct { + name string + params ChannelMonitorCreateParams + wantErr error + }{ + { + name: "probe requires endpoint", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, + APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorInvalidEndpoint, + }, + { + name: "probe requires api key", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, + Endpoint: "https://api.openai.com", IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorMissingAPIKey, + }, + { + name: "quota drops endpoint and api key requirements", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuota, + IntervalSeconds: 60, AccountID: &accountID, + }, + wantErr: nil, // primary_model 默认 "quota" + }, + { + name: "quota requires account", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, + IntervalSeconds: 60, PrimaryModel: "quota", + }, + wantErr: ErrChannelMonitorAccountRequired, + }, + { + name: "quota_probe requires endpoint and api key too", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "kimi-k2", + }, + wantErr: ErrChannelMonitorInvalidEndpoint, + }, + { + name: "antigravity probe unsupported", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeProbe, + Endpoint: "https://example.com", APIKey: "k", + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + name: "antigravity quota_probe unsupported", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuotaProbe, + Endpoint: "https://example.com", APIKey: "k", + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + name: "unknown mode rejected", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: "auto", + Endpoint: "https://api.openai.com", APIKey: "sk", + IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + // quota_probe 仍要打真实探活请求:空模型必须报错,不再用 "quota" 占位。 + name: "quota_probe requires primary model", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, + Endpoint: "https://api.kimi.com", APIKey: "sk", + IntervalSeconds: 60, AccountID: &accountID, + }, + wantErr: ErrChannelMonitorMissingPrimaryModel, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCreateParams(tc.params) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) { + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, "")) + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " ")) + // quota_probe 仍要打真实探活请求:空模型返回 ""(由上层报 MissingPrimaryModel), + // 不再用 "quota" 占位打 model="quota" 的请求。 + require.Equal(t, "", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "")) + // grok 分支在纯 quota 占位之后:grok+quota 占位 "quota", + // grok 探活(probe/quota_probe)默认轻量测活模型。 + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuota, "")) + require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, "")) + require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuotaProbe, "")) + // 探活模式沿用原语义:其余必填(空串报错在 validateCreateParams)。 + require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2")) +} + +func TestProviderProbeCapabilityMatrix(t *testing.T) { + require.False(t, providerSupportsProbe(MonitorProviderAntigravity)) + for _, p := range []string{ + MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini, + MonitorProviderGrok, MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, + } { + require.True(t, providerSupportsProbe(p), p) + } + for _, p := range []string{ + MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini, + MonitorProviderGrok, MonitorProviderAntigravity, + MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, + } { + require.NoError(t, validateProvider(p), p) + } +} + +// --- 关联账号校验 --- + +func TestValidateLinkedAccount_Matrix(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + fetcher := newQuotaModeFetcher(map[int64]*Account{ + 1: {ID: 1, Platform: domain.PlatformKimi}, + }, nil) + svc.SetQuotaFetcher(fetcher) + + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, nil)) + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(0))) + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1))) + require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderZhipu, int64Ptr(1)), ErrChannelMonitorProviderIncompatible) + require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(404)), ErrChannelMonitorAccountRequired) + + noFetcher := NewChannelMonitorService(nil, nil) + require.ErrorIs(t, noFetcher.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)), ErrChannelMonitorAccountRequired) +} + +func TestRevalidateLinkedAccount_QuotaErrorsProbeUnbinds(t *testing.T) { + fetcher := newQuotaModeFetcher(nil, nil) // 账号一律加载失败 + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(fetcher) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(9)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountRequired) + require.NotNil(t, quota.AccountID) + + probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(9)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID, "probe mode should silently unbind stale account") + + quotaNoAccount := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quotaNoAccount), ErrChannelMonitorAccountRequired) +} + +func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 2: {ID: 2, Platform: domain.PlatformDeepseek}, + }, nil)) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorProviderIncompatible) + + probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID) +} + +// 能力矩阵:与 fetchUncached 路由一一对应,创建期拦截注定运行期永久 error 的组合。 +func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantErr error + }{ + { + name: "deepseek coding has no quota endpoint", + account: &Account{ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + // 自定义域名 kimi coding:GetCodingPlanProvider 识别不到 → 无额度端点。 + name: "custom-domain kimi coding unsupported", + account: &Account{ID: 2, Platform: domain.PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://cw.example.com"}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "kimi coding default endpoint ok", + account: &Account{ID: 3, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "zhipu coding default endpoint ok", + account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "zhipu payg has no balance endpoint", + account: &Account{ID: 5, Platform: domain.PlatformZhipu}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "kimi payg ok", + account: &Account{ID: 6, Platform: domain.PlatformKimi}, + }, + { + name: "deepseek payg ok", + account: &Account{ID: 7, Platform: domain.PlatformDeepseek}, + }, + { + name: "anthropic api key cannot query usage", + account: &Account{ID: 8, Platform: domain.PlatformAnthropic, Type: AccountTypeAPIKey}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "anthropic oauth ok", + account: &Account{ID: 9, Platform: domain.PlatformAnthropic, Type: AccountTypeOAuth}, + }, + { + name: "anthropic setup token ok (local estimation)", + account: &Account{ID: 10, Platform: domain.PlatformAnthropic, Type: AccountTypeSetupToken}, + }, + { + name: "openai api key cannot query usage", + account: &Account{ID: 11, Platform: domain.PlatformOpenAI, Type: AccountTypeAPIKey}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "openai oauth ok", + account: &Account{ID: 12, Platform: domain.PlatformOpenAI, Type: AccountTypeOAuth}, + }, + { + // 防过度拦截:gemini/grok/antigravity 走本地统计/值通道降级,不会永久 error。 + name: "gemini api key ok", + account: &Account{ID: 13, Platform: domain.PlatformGemini, Type: AccountTypeAPIKey}, + }, + { + name: "grok ok", + account: &Account{ID: 14, Platform: domain.PlatformGrok}, + }, + { + name: "antigravity ok", + account: &Account{ID: 15, Platform: domain.PlatformAntigravity}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := monitorAccountQuotaCapability(tc.account) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +func TestValidateLinkedAccount_CapabilityRejected(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 1: {ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil)) + + err := svc.validateLinkedAccount(context.Background(), MonitorProviderDeepseek, int64Ptr(1)) + require.ErrorIs(t, err, ErrChannelMonitorAccountNotSupportable) +} + +func TestRevalidateLinkedAccount_Capability(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 2: {ID: 2, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil)) + + quota := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountNotSupportable) + require.NotNil(t, quota.AccountID, "quota mode keeps the binding for the admin to fix") + + probe := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID, "probe mode should silently unbind unusable account") +} + +// provider-only 更新不得绕过 provider × check_mode 组合校验。 +func TestApplyMonitorUpdate_ProviderOnlyRevalidatesCheckMode(t *testing.T) { + probeKimi := func() *ChannelMonitor { + return &ChannelMonitor{ + Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.kimi.com", PrimaryModel: "kimi-k2", + CheckMode: MonitorCheckModeProbe, + } + } + + provider := MonitorProviderAntigravity + err := applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{Provider: &provider}) + require.ErrorIs(t, err, ErrChannelMonitorInvalidCheckMode) + + // 带上 check_mode/account_id 的完整切换合法。 + accountID := int64(3) + err = applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{ + Provider: &provider, CheckMode: strPtr(MonitorCheckModeQuota), AccountID: &accountID, + }) + require.NoError(t, err) + + // 存量非法行(antigravity+probe)仅改名/停用不被砖化。 + legacy := &ChannelMonitor{ + Provider: MonitorProviderAntigravity, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://example.com", PrimaryModel: "gemini-3-pro", + CheckMode: MonitorCheckModeProbe, + } + newName := "renamed" + require.NoError(t, applyMonitorUpdate(legacy, ChannelMonitorUpdateParams{Name: &newName})) +} + +// --- quota → probe 切换的 key 管控(validateProbeAPIKey) --- + +func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) { + svc := NewChannelMonitorService(nil, &duplicateChannelMonitorEncryptor{}) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, APIKey: "NEW:"} + require.NoError(t, svc.validateProbeAPIKey(quota, "")) // quota 模式不管 key + + quota.CheckMode = MonitorCheckModeProbe + // 存量密文解出空明文(quota 监控存的加密空串)→ 必须重填 key。 + require.ErrorIs(t, svc.validateProbeAPIKey(quota, ""), ErrChannelMonitorMissingAPIKey) + // 提供新明文 key → 放行。 + require.NoError(t, svc.validateProbeAPIKey(quota, "sk-fresh")) + // 密文解出非空明文 → 放行。 + require.NoError(t, svc.validateProbeAPIKey( + &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, APIKey: "OLD:sk-live"}, "")) +} + +// --- Duplicate:quota 模式空明文重加密 --- + +func TestDuplicateChannelMonitorQuotaModeReencryptsEmptyKey(t *testing.T) { + accountID := int64(9) + source := &ChannelMonitor{ + ID: 42, + Name: "kimi-quota", + Provider: MonitorProviderKimi, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: "", + APIKey: "OLD:", // 解密为空串(quota 监控的加密空 key) + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: &accountID, + } + repo := &duplicateChannelMonitorRepoStub{source: source} + service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + + dup, err := service.Duplicate(context.Background(), 42, 7, "admin:7", "op-1") + require.NoError(t, err) + require.Equal(t, MonitorCheckModeQuota, dup.CheckMode) + require.NotNil(t, dup.AccountID) + require.Equal(t, accountID, *dup.AccountID) + require.Empty(t, dup.APIKey, "plaintext stays empty for quota monitors") + require.Len(t, repo.created, 1) + require.Equal(t, "NEW:", repo.created[0].APIKey, "empty key must be re-encrypted, not dropped") +} diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 248b72133d..b08a623ce5 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "golang.org/x/sync/errgroup" ) @@ -77,6 +78,10 @@ type ChannelMonitorService struct { // scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。 // 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。 scheduler MonitorScheduler + // quotaFetcher 由 wire 通过 SetQuotaFetcher 注入(accountUsage/CN 服务在本服务 + // 之后构造,构造参数注入会破坏既有依赖顺序)。nil 时 fail-closed: + // 配额模式的检测产出「未配置」错误快照,Create/Update 关联账号直接报错。 + quotaFetcher *ChannelMonitorQuotaFetcher } const maxChannelMonitorNameRunes = 100 @@ -150,6 +155,10 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea if err := validateExtraHeaders(p.ExtraHeaders); err != nil { return nil, err } + if err := s.validateLinkedAccount(ctx, p.Provider, p.AccountID); err != nil { + return nil, err + } + checkMode := defaultCheckMode(p.CheckMode) encrypted, err := s.encryptor.Encrypt(p.APIKey) if err != nil { return nil, fmt.Errorf("encrypt api key: %w", err) @@ -160,7 +169,7 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea APIMode: defaultAPIMode(p.APIMode), Endpoint: normalizeEndpoint(p.Endpoint), APIKey: encrypted, // 注意:传入 repository 时该字段为密文 - PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel), + PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel), ExtraModels: normalizeModels(p.ExtraModels), GroupName: strings.TrimSpace(p.GroupName), Enabled: p.Enabled, @@ -171,6 +180,8 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders), BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode), BodyOverride: p.BodyOverride, + CheckMode: checkMode, + AccountID: cloneInt64Pointer(p.AccountID), } if err := s.repo.Create(ctx, m); err != nil { return nil, fmt.Errorf("create channel monitor: %w", err) @@ -236,6 +247,8 @@ func (s *ChannelMonitorService) Duplicate( ExtraHeaders: cloneChannelMonitorHeaders(source.ExtraHeaders), BodyOverrideMode: source.BodyOverrideMode, BodyOverride: bodyOverride, + CheckMode: defaultCheckMode(source.CheckMode), + AccountID: cloneInt64Pointer(source.AccountID), DuplicateOperationID: operationID, } if err := s.repo.Create(ctx, duplicate); err != nil { @@ -290,11 +303,22 @@ func (s *ChannelMonitorService) decryptAPIKeyForDuplicate(source *ChannelMonitor return "", ErrChannelMonitorAPIKeyDecryptFailed } plain, err := s.encryptor.Decrypt(source.APIKey) - if err != nil || strings.TrimSpace(plain) == "" { + if err != nil { slog.Warn("channel_monitor: decrypt api key for duplicate failed", "monitor_id", source.ID, "error", err) return "", ErrChannelMonitorAPIKeyDecryptFailed } + // quota 模式明文为空串是合法状态(api_key_encrypted 存的是加密空串): + // 重加密空串即可。若在此报错,克隆出的配额监控会被 runner 当作 + // 解密失败而 Unschedule,静默停摆。 + if strings.TrimSpace(plain) == "" { + if monitorCheckModeUsesQuota(defaultCheckMode(source.CheckMode)) { + return "", nil + } + slog.Warn("channel_monitor: decrypted api key for duplicate is empty", + "monitor_id", source.ID) + return "", ErrChannelMonitorAPIKeyDecryptFailed + } return plain, nil } @@ -343,10 +367,16 @@ func cloneChannelMonitorJSONMap(source map[string]any) (map[string]any, error) { } // validateCreateParams 把 Create 入参的所有校验聚拢为一个函数,避免 Create 主体超过 30 行。 +// 按 check_mode 分支:probe 沿用 endpoint+api_key 必填;quota 只需关联账号; +// quota_probe 两者皆需。 func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateProvider(p.Provider); err != nil { return err } + checkMode := defaultCheckMode(p.CheckMode) + if err := validateCheckMode(p.Provider, checkMode); err != nil { + return err + } if err := validateAPIMode(p.Provider, p.APIMode); err != nil { return err } @@ -356,18 +386,46 @@ func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateJitter(p.JitterSeconds, p.IntervalSeconds); err != nil { return err } - if err := validateEndpoint(p.Endpoint); err != nil { - return err + usesQuota := monitorCheckModeUsesQuota(checkMode) + // probe 分支(含 quota_probe 的探活部分)仍需 endpoint + api_key; + // quota 模式 endpoint/api_key 留空,避免要求用户填无意义的占位值。 + if checkMode != MonitorCheckModeQuota { + if err := validateEndpoint(p.Endpoint); err != nil { + return err + } + if strings.TrimSpace(p.APIKey) == "" { + return ErrChannelMonitorMissingAPIKey + } } - if strings.TrimSpace(p.APIKey) == "" { - return ErrChannelMonitorMissingAPIKey + if usesQuota && (p.AccountID == nil || *p.AccountID <= 0) { + return ErrChannelMonitorAccountRequired } - if normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel) == "" { + if normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel) == "" { return ErrChannelMonitorMissingPrimaryModel } return nil } +// validateLinkedAccount 校验关联账号存在、平台与监控 provider 一致、且能充当 +// 配额数据源(能力拦截,见 monitorAccountQuotaCapability)。 +// fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。 +func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error { + if accountID == nil || *accountID <= 0 { + return nil + } + if s.quotaFetcher == nil { + return ErrChannelMonitorAccountRequired + } + account, err := s.quotaFetcher.LoadAccount(ctx, *accountID) + if err != nil || account == nil { + return ErrChannelMonitorAccountRequired + } + if account.Platform != provider { + return ErrChannelMonitorProviderIncompatible + } + return monitorAccountQuotaCapability(account) +} + // Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。 func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelMonitorUpdateParams) (*ChannelMonitor, error) { existing, err := s.repo.GetByID(ctx, id) @@ -382,6 +440,14 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM if err != nil { return nil, err } + if err := s.validateProbeAPIKey(existing, newPlainAPIKey); err != nil { + return nil, err + } + if p.Provider != nil || p.CheckMode != nil || p.AccountID != nil { + if err := s.revalidateLinkedAccount(ctx, existing); err != nil { + return nil, err + } + } if err := s.repo.Update(ctx, existing); err != nil { return nil, fmt.Errorf("update channel monitor: %w", err) @@ -401,6 +467,85 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM return existing, nil } +// validateMonitorModeFields 校验 check_mode 与其它字段的组合约束 +// (在 provider/check_mode/account_id/endpoint 全部应用后调用): +// - quota / quota_probe 必须关联账号 +// - probe / quota_probe 必须持有 endpoint(探活目标) +func validateMonitorModeFields(m *ChannelMonitor) error { + checkMode := defaultCheckMode(m.CheckMode) + if monitorCheckModeUsesQuota(checkMode) && m.AccountID == nil { + return ErrChannelMonitorAccountRequired + } + if checkMode != MonitorCheckModeQuota && strings.TrimSpace(m.Endpoint) == "" { + return ErrChannelMonitorInvalidEndpoint + } + return nil +} + +// validateProbeAPIKey 探活模式(probe / quota_probe)必须持有可用明文 key: +// 存量密文解密为空串(quota 监控切回探活但未重填 key)时拒绝。 +// 密文损坏的情况交给既有 APIKeyDecryptFailed 链路(Get/RunCheck 会显式报错)。 +func (s *ChannelMonitorService) validateProbeAPIKey(m *ChannelMonitor, newPlainKey string) error { + if defaultCheckMode(m.CheckMode) == MonitorCheckModeQuota { + return nil + } + if strings.TrimSpace(newPlainKey) != "" { + return nil + } + if strings.TrimSpace(m.APIKey) == "" { + return ErrChannelMonitorMissingAPIKey + } + plain, err := s.encryptor.Decrypt(m.APIKey) + if err != nil { + return nil + } + if strings.TrimSpace(plain) == "" { + return ErrChannelMonitorMissingAPIKey + } + return nil +} + +// revalidateLinkedAccount 在 provider/check_mode/account_id 任一变化后复核关联账号: +// - 账号已被删除或平台失配:probe 模式自动解绑(静默修复), +// quota 模式显式报错(配额监控必须有可用数据源) +func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m *ChannelMonitor) error { + usesQuota := monitorCheckModeUsesQuota(defaultCheckMode(m.CheckMode)) + if m.AccountID == nil { + if usesQuota { + return ErrChannelMonitorAccountRequired + } + return nil + } + if s.quotaFetcher == nil { + return ErrChannelMonitorAccountRequired + } + account, err := s.quotaFetcher.LoadAccount(ctx, *m.AccountID) + if err != nil || account == nil { + if usesQuota { + return ErrChannelMonitorAccountRequired + } + m.AccountID = nil + return nil + } + if account.Platform != m.Provider { + if usesQuota { + return ErrChannelMonitorProviderIncompatible + } + m.AccountID = nil + return nil + } + // 能力失配(如 deepseek coding / zhipu payg / API-Key 型海外账号): + // quota 模式显式报错(有该类存量监控时编辑会被拦,出路是换账号或切 probe), + // probe 模式账号无用途,静默解绑。 + if err := monitorAccountQuotaCapability(account); err != nil { + if usesQuota { + return err + } + m.AccountID = nil + } + return nil +} + // applyAPIKeyUpdate 处理 Update 中的 APIKey 字段: // - 入参 raw 为 nil 或空白:不修改 existing.APIKey(仍为密文),返回 updated=false // - 非空:加密后写入 existing.APIKey;同时把明文返回给调用方, @@ -454,6 +599,9 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model // 写历史记录并更新 last_checked_at。返回每个模型的检测结果。 // 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测; // mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。 +// +// 按 check_mode 分派:probe(默认,现状探活)/ quota(仅查关联账号配额, +// 零 LLM 成本)/ quota_probe(探活 + 配额快照挂主模型行)。 func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) { rt := s.probeRuntime(ctx) if !rt.Enabled { @@ -466,14 +614,59 @@ func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*Chec if err != nil { return nil, err } - if m.APIKeyDecryptFailed { + checkMode := defaultCheckMode(m.CheckMode) + if checkMode != MonitorCheckModeQuota && m.APIKeyDecryptFailed { return nil, ErrChannelMonitorAPIKeyDecryptFailed } - results := s.runChecksConcurrent(ctx, m) + + var results []*CheckResult + switch checkMode { + case MonitorCheckModeQuota: + results = s.runQuotaOnlyCheck(ctx, m) + case MonitorCheckModeQuotaProbe: + results = s.runChecksConcurrent(ctx, m) + attachQuotaSnapshot(results, s.fetchQuotaSnapshot(ctx, m)) + default: + results = s.runChecksConcurrent(ctx, m) + } s.persistCheckResults(ctx, m, results) return results, nil } +// runQuotaOnlyCheck quota 模式:一次配额抓取 → 单条 CheckResult +// (Model=PrimaryModel,默认 "quota";无 ping/latency,状态由快照推导)。 +func (s *ChannelMonitorService) runQuotaOnlyCheck(ctx context.Context, m *ChannelMonitor) []*CheckResult { + snapshot := s.fetchQuotaSnapshot(ctx, m) + res := deriveQuotaCheckResult(snapshot, m.PrimaryModel, time.Now()) + res.Quota = snapshot + return []*CheckResult{res} +} + +// fetchQuotaSnapshot 抓取关联账号配额。未关联账号 / fetcher 未注入时返回 +// 显式错误快照(不返回 error,保证检测周期与历史时间线连续)。 +func (s *ChannelMonitorService) fetchQuotaSnapshot(ctx context.Context, m *ChannelMonitor) *domain.MonitorQuotaSnapshot { + if m.AccountID == nil { + return quotaErrorSnapshot("usage", "linked account not found", time.Now()) + } + if s.quotaFetcher == nil { + return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now()) + } + return s.quotaFetcher.Fetch(ctx, *m.AccountID) +} + +// attachQuotaSnapshot quota_probe:把配额快照挂到主模型行(results[0])。 +// 配额失败不改变探活状态,仅在探活 message 为空时附注失败原因。 +func attachQuotaSnapshot(results []*CheckResult, snapshot *domain.MonitorQuotaSnapshot) { + if len(results) == 0 || snapshot == nil { + return + } + primary := results[0] + primary.Quota = snapshot + if !snapshot.Success && strings.TrimSpace(primary.Message) == "" { + primary.Message = truncateMessage("quota fetch failed: " + snapshot.Error) + } +} + // persistCheckResults 写入本次检测的历史记录并更新 last_checked_at。 // 任一写库失败都只记日志,不影响调用方拿到 results(与 MVP 期望一致:宁可漏记历史也要先返回结果)。 func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *ChannelMonitor, results []*CheckResult) { @@ -487,6 +680,7 @@ func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *Chan PingLatencyMs: r.PingLatencyMs, Message: r.Message, CheckedAt: r.CheckedAt, + Quota: r.Quota, }) } if err := s.repo.InsertHistoryBatch(ctx, rows); err != nil { @@ -541,6 +735,14 @@ func (s *ChannelMonitorService) SetScheduler(sched MonitorScheduler) { s.scheduler = sched } +// SetQuotaFetcher 由 wire 注入配额抓取器(账号侧用量服务聚合)。 +func (s *ChannelMonitorService) SetQuotaFetcher(fetcher *ChannelMonitorQuotaFetcher) { + if s == nil { + return + } + s.quotaFetcher = fetcher +} + // ListEnabledMonitors 返回所有 enabled=true 的监控(解密后),供 runner 启动时建立任务表。 func (s *ChannelMonitorService) ListEnabledMonitors(ctx context.Context) ([]*ChannelMonitor, error) { all, err := s.repo.ListEnabled(ctx) @@ -693,14 +895,41 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) providerChanged = existing.Provider != *p.Provider existing.Provider = *p.Provider } - if p.Endpoint != nil { - if err := validateEndpoint(*p.Endpoint); err != nil { + if p.CheckMode != nil { + existing.CheckMode = defaultCheckMode(*p.CheckMode) + } + // provider 与 check_mode 任一变化后统一复核组合矩阵:provider-only 更新 + // (如把 probe 监控的 provider 改成 antigravity)也不得落库非法组合,否则 + // 运行期恒 error。条件限定避免把存量非法行的 name/enabled-only 更新也判死 + // (否则连改名/停用都无法操作)。 + if p.Provider != nil || p.CheckMode != nil { + if err := validateCheckMode(existing.Provider, defaultCheckMode(existing.CheckMode)); err != nil { return err } + } + if p.AccountID != nil { + if *p.AccountID > 0 { + id := *p.AccountID + existing.AccountID = &id + } else { + existing.AccountID = nil // 0 = 清空关联 + } + } + if p.Endpoint != nil { + // quota 模式允许清空 endpoint(校验由 validateMonitorModeFields 兜底)。 + if strings.TrimSpace(*p.Endpoint) != "" { + if err := validateEndpoint(*p.Endpoint); err != nil { + return err + } + } existing.Endpoint = normalizeEndpoint(*p.Endpoint) } + // 模式与字段的组合校验(provider/check_mode/account_id/endpoint 全部应用后)。 + if err := validateMonitorModeFields(existing); err != nil { + return err + } if p.PrimaryModel != nil { - primaryModel := normalizeMonitorPrimaryModel(existing.Provider, *p.PrimaryModel) + primaryModel := normalizeMonitorPrimaryModel(existing.Provider, defaultCheckMode(existing.CheckMode), *p.PrimaryModel) if primaryModel == "" { return ErrChannelMonitorMissingPrimaryModel } diff --git a/backend/internal/service/channel_monitor_service_grok_test.go b/backend/internal/service/channel_monitor_service_grok_test.go index 20c9db2666..e3a89075f4 100644 --- a/backend/internal/service/channel_monitor_service_grok_test.go +++ b/backend/internal/service/channel_monitor_service_grok_test.go @@ -9,6 +9,7 @@ func TestApplyMonitorUpdate_ProviderOnlySwitchToGrokUsesDefaultModel(t *testing. existing := &ChannelMonitor{ Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeResponses, + Endpoint: "https://api.openai.com/v1", PrimaryModel: "gpt-5", IntervalSeconds: 60, } @@ -31,6 +32,7 @@ func TestApplyMonitorUpdate_SwitchToGrokPreservesExplicitModel(t *testing.T) { existing := &ChannelMonitor{ Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.openai.com/v1", PrimaryModel: "gpt-5", IntervalSeconds: 60, } @@ -52,6 +54,7 @@ func TestApplyMonitorUpdate_SameGrokProviderDoesNotResetExistingModel(t *testing existing := &ChannelMonitor{ Provider: MonitorProviderGrok, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.x.ai", PrimaryModel: "grok-4.3", IntervalSeconds: 60, } diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go index 20ab1da935..138593e0bd 100644 --- a/backend/internal/service/channel_monitor_types.go +++ b/backend/internal/service/channel_monitor_types.go @@ -1,6 +1,10 @@ package service -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) // MonitorBodyOverrideMode 自定义请求体处理模式。 // @@ -45,6 +49,11 @@ type ChannelMonitor struct { CreatedAt time.Time UpdatedAt time.Time + // 配额模式(check_mode = quota / quota_probe): + // 关联已有账号复用账号侧用量服务,Endpoint/APIKey 可为空(quota 模式)。 + CheckMode string // probe(默认)/ quota / quota_probe;空串按 probe 处理 + AccountID *int64 // 关联账号 ID;账号删除后被 DB 置空(监控保留并报「账号未关联」) + // 请求自定义快照(来自模板拷贝 or 用户手填,运行时直接读取) TemplateID *int64 // 仅用于 UI 分组 + 一键应用,运行时不用 ExtraHeaders map[string]string // 与 adapter 默认 headers 合并,用户优先 @@ -89,6 +98,10 @@ type ChannelMonitorCreateParams struct { ExtraHeaders map[string]string BodyOverrideMode string BodyOverride map[string]any + + // 配额模式:CheckMode 空串默认 probe;quota/quota_probe 必须关联账号。 + CheckMode string + AccountID *int64 } // ChannelMonitorUpdateParams 更新参数(指针字段表示"未提供则不更新")。 @@ -112,6 +125,11 @@ type ChannelMonitorUpdateParams struct { ExtraHeaders *map[string]string BodyOverrideMode *string BodyOverride *map[string]any + + // 配额模式:CheckMode nil = 不更新;AccountID nil = 不更新, + // 指向 0 = 清空关联(退回 probe 模式时由 CheckMode 分支兜底)。 + CheckMode *string + AccountID *int64 } // CheckResult 单个模型一次检测的结果。 @@ -122,6 +140,8 @@ type CheckResult struct { PingLatencyMs *int Message string CheckedAt time.Time + // Quota 配额模式附带快照(quota 模式唯一数据;quota_probe 挂在主模型行)。 + Quota *domain.MonitorQuotaSnapshot } // UserMonitorView 用户只读视图:监控概览(含主模型最近状态 + 7d 可用率 + 附加模型最近状态)。 @@ -137,6 +157,9 @@ type UserMonitorView struct { Availability7d float64 // 0-100 ExtraModels []ExtraModelStatus Timeline []UserMonitorTimelinePoint // 主模型最近 N 个历史点(按 checked_at DESC,最新在前) + // LatestQuota 主模型最近一次配额快照;channel_monitor_show_quota=false + // 时由 handler 服务端剥离。 + LatestQuota *domain.MonitorQuotaSnapshot } // UserMonitorTimelinePoint 用户视图 timeline 单点数据(去除 message 以减小响应体)。 @@ -183,6 +206,7 @@ type ChannelMonitorHistoryRow struct { PingLatencyMs *int Message string CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorHistoryEntry 历史记录查询返回行(含 ent 主键 ID)。 @@ -194,6 +218,7 @@ type ChannelMonitorHistoryEntry struct { PingLatencyMs *int Message string CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorLatest 最近一次检测的简明信息(用于 UserMonitorView 聚合)。 @@ -203,6 +228,7 @@ type ChannelMonitorLatest struct { LatencyMs *int PingLatencyMs *int CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorAvailability 单个模型在某窗口内的可用率与平均延迟(用于 UserMonitorDetail 聚合)。 @@ -223,4 +249,5 @@ type MonitorStatusSummary struct { PrimaryLatencyMs *int Availability7d float64 // 0-100,无历史时为 0 ExtraModels []ExtraModelStatus + LatestQuota *domain.MonitorQuotaSnapshot // 主模型最近配额快照(配额模式) } diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 7740dc83b7..6b3b9fd81f 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -9,15 +9,81 @@ import ( // 渠道监控参数校验与归一化辅助函数。 // 校验失败一律返回 channel_monitor_const.go 中预定义的 Err* 错误,错误信息不含具体 IP/hostname,避免泄露内网拓扑。 +// monitorProviders 渠道监控支持的全部 provider(与迁移 226 的 CHECK 约束一致)。 +// 不再以 adapter 表为唯一来源:antigravity 没有探活 adapter,但支持配额模式。 +// +//nolint:gochecknoglobals // 静态查表,初始化后不变。 +var monitorProviders = map[string]struct{}{ + MonitorProviderOpenAI: {}, + MonitorProviderAnthropic: {}, + MonitorProviderGemini: {}, + MonitorProviderGrok: {}, + MonitorProviderAntigravity: {}, + MonitorProviderKimi: {}, + MonitorProviderZhipu: {}, + MonitorProviderDeepseek: {}, +} + +// probeCapableProviders 支持探活(probe / quota_probe)的 provider。 +// antigravity 上游无 Chat/Responses 可打(仅 IDE 代理形态),只允许配额模式。 +// +//nolint:gochecknoglobals // 静态查表,初始化后不变。 +var probeCapableProviders = map[string]struct{}{ + MonitorProviderOpenAI: {}, + MonitorProviderAnthropic: {}, + MonitorProviderGemini: {}, + MonitorProviderGrok: {}, + MonitorProviderKimi: {}, + MonitorProviderZhipu: {}, + MonitorProviderDeepseek: {}, +} + // validateProvider 校验 provider 字符串。 -// 唯一来源于 providerAdapters:新增 provider 只需要在 channel_monitor_checker.go 注册 adapter。 func validateProvider(p string) error { - if !isSupportedProvider(p) { + if _, ok := monitorProviders[p]; !ok { return ErrChannelMonitorInvalidProvider } return nil } +// providerSupportsProbe 该 provider 是否注册了探活 adapter(antigravity 为 false)。 +func providerSupportsProbe(p string) bool { + _, ok := probeCapableProviders[p] + return ok +} + +// defaultCheckMode 空串归一为 probe,保证存量数据与旧客户端兼容。 +func defaultCheckMode(checkMode string) string { + if strings.TrimSpace(checkMode) == "" { + return MonitorCheckModeProbe + } + return strings.TrimSpace(checkMode) +} + +// monitorCheckModeUsesQuota 该模式是否需要关联账号查配额。 +func monitorCheckModeUsesQuota(checkMode string) bool { + return checkMode == MonitorCheckModeQuota || checkMode == MonitorCheckModeQuotaProbe +} + +// validateCheckMode 校验 check_mode 与 provider 的组合矩阵: +// +// provider | probe | quota | quota_probe +// ------------------------+-------+-------+------------ +// openai/anthropic/... | Y | Y | Y +// antigravity(无 adapter)| N | Y | N +func validateCheckMode(provider, checkMode string) error { + checkMode = defaultCheckMode(checkMode) + switch checkMode { + case MonitorCheckModeProbe, MonitorCheckModeQuota, MonitorCheckModeQuotaProbe: + default: + return ErrChannelMonitorInvalidCheckMode + } + if checkMode != MonitorCheckModeQuota && !providerSupportsProbe(provider) { + return ErrChannelMonitorInvalidCheckMode + } + return nil +} + // validateAPIMode 校验 provider 与 api_mode 的组合。 // responses 只对 OpenAI 有意义;其它 provider 使用 chat_completions 作为默认占位。 func validateAPIMode(provider, apiMode string) error { @@ -124,16 +190,61 @@ func normalizeModels(in []string) []string { return out } -// normalizeMonitorPrimaryModel applies the Grok health-check default while -// preserving the existing required-model behavior for every other provider. -func normalizeMonitorPrimaryModel(provider, model string) string { +// normalizeMonitorPrimaryModel applies provider/check_mode defaults: +// - pure quota mode never sends requests: placeholder "quota" keeps +// primary_model NOT NULL (history rows / timeline need no special-casing) +// - quota_probe still sends a real probe request: empty model returns "" +// so validateCreateParams / applyMonitorUpdate report +// ErrChannelMonitorMissingPrimaryModel instead of probing model="quota" +// - Grok probing (probe/quota_probe) defaults to the lightweight check model +func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { model = strings.TrimSpace(model) + if model == "" && defaultCheckMode(checkMode) == MonitorCheckModeQuota { + return MonitorDefaultQuotaModel + } if model == "" && provider == MonitorProviderGrok { return MonitorDefaultGrokModel } return model } +// monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与 +// fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 / +// 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合: +// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu +// (deepseek coding、自定义域名 kimi coding 无法路由额度端点) +// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无) +// - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error) +// - openai:OAuth(API-Key 型无 usage 通道) +// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 +func monitorAccountQuotaCapability(account *Account) error { + switch account.Platform { + case PlatformKimi, PlatformZhipu, PlatformDeepseek: + if account.IsCodingPlan() { + if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu { + return ErrChannelMonitorAccountNotSupportable + } + return nil + } + if account.Platform == PlatformZhipu { + return ErrChannelMonitorAccountNotSupportable + } + return nil + case PlatformAnthropic: + if account.Type == AccountTypeOAuth || account.Type == AccountTypeSetupToken { + return nil + } + return ErrChannelMonitorAccountNotSupportable + case PlatformOpenAI: + if account.Type == AccountTypeOAuth { + return nil + } + return ErrChannelMonitorAccountNotSupportable + default: + return nil + } +} + // defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。 func defaultAPIMode(apiMode string) string { if strings.TrimSpace(apiMode) == "" { diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go deleted file mode 100644 index 1e4dee2717..0000000000 --- a/backend/internal/service/channel_plaza.go +++ /dev/null @@ -1,256 +0,0 @@ -package service - -import ( - "context" - "fmt" - "sort" - "strings" -) - -// PlazaOfficialPricing 模型广场展示用的 LiteLLM 官方参考价(USD per token)。 -// 字段为 nil 表示官方数据中该项缺失(0 视为未配置)。 -type PlazaOfficialPricing struct { - InputPrice *float64 - OutputPrice *float64 - CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation) - CacheWrite1hPrice *float64 // 1h 缓存写入(LiteLLM cache_creation_above_1hr) - CacheReadPrice *float64 -} - -// PlazaModel 模型广场中单个模型条目:渠道定价 + 官方参考价。 -type PlazaModel struct { - Name string - Platform string - Pricing *ChannelModelPricing - OfficialPricing *PlazaOfficialPricing -} - -// PlazaGroup 模型广场中以分组为顶层的条目。 -// -// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 -// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 -// 具体平台),与「可用渠道」页口径一致。 -type PlazaGroup struct { - ID int64 - Name string - Description string - Platform string - SubscriptionType string - RateMultiplier float64 - PeakRateEnabled bool - PeakStart string - PeakEnd string - PeakRateMultiplier float64 - IsExclusive bool - // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付 - // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 - ImageRateIndependent bool - ImageRateMultiplier float64 - Models []PlazaModel -} - -// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。 -// -// 聚合口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、 -// 平台隔离),仅把顶层从渠道换成分组: -// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定; -// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换; -// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价, -// 见 plazaImageDisplayPricing); -// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil); -// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称), -// 组内模型按名称排序。 -// -// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。 -func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, error) { - channels, err := s.repo.ListAll(ctx) - if err != nil { - return nil, fmt.Errorf("list channels: %w", err) - } - groups, err := s.groupRepo.ListActive(ctx) - if err != nil { - return nil, fmt.Errorf("list active groups: %w", err) - } - - sort.SliceStable(channels, func(i, j int) bool { - return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name) - }) - - byGroup := make(map[int64]*PlazaGroup, len(groups)) - groupEnt := make(map[int64]*Group, len(groups)) - order := make([]int64, 0, len(groups)) - for i := range groups { - g := &groups[i] - byGroup[g.ID] = &PlazaGroup{ - ID: g.ID, - Name: g.Name, - Description: g.Description, - Platform: g.Platform, - SubscriptionType: g.SubscriptionType, - RateMultiplier: g.RateMultiplier, - PeakRateEnabled: g.PeakRateEnabled, - PeakStart: g.PeakStart, - PeakEnd: g.PeakEnd, - PeakRateMultiplier: g.PeakRateMultiplier, - IsExclusive: g.IsExclusive, - ImageRateIndependent: g.ImageRateIndependent, - ImageRateMultiplier: g.ImageRateMultiplier, - } - groupEnt[g.ID] = g - order = append(order, g.ID) - } - - type modelKey struct { - platform string - name string - } - // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models - modelIdx := make(map[int64]map[modelKey]int, len(groups)) - for i := range channels { - ch := &channels[i] - if ch.Status != StatusActive { - continue - } - ch.normalizeBillingModelSource() - supported := ch.SupportedModels() - s.fillGlobalPricingFallback(supported) - - for _, gid := range ch.GroupIDs { - pg, ok := byGroup[gid] - if !ok { - continue - } - idx := modelIdx[gid] - if idx == nil { - idx = make(map[modelKey]int, len(supported)) - modelIdx[gid] = idx - } - for j := range supported { - m := supported[j] - if pg.Platform == PlatformComposite { - if !isConcreteRequestPlatform(m.Platform) { - continue - } - } else if m.Platform != pg.Platform { - continue - } - pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid]) - key := modelKey{platform: m.Platform, name: m.Name} - if at, seen := idx[key]; seen { - // 先见者胜;仅当已存条目无定价而新条目有定价时升级。 - if pg.Models[at].Pricing == nil && pricing != nil { - pg.Models[at].Pricing = pricing - } - continue - } - idx[key] = len(pg.Models) - pg.Models = append(pg.Models, PlazaModel{ - Name: m.Name, - Platform: m.Platform, - Pricing: pricing, - }) - } - } - } - - officialMemo := make(map[string]*PlazaOfficialPricing) - out := make([]PlazaGroup, 0, len(order)) - for _, gid := range order { - pg := byGroup[gid] - if len(pg.Models) == 0 { - continue - } - sort.SliceStable(pg.Models, func(i, j int) bool { - if pg.Models[i].Name != pg.Models[j].Name { - return pg.Models[i].Name < pg.Models[j].Name - } - return pg.Models[i].Platform < pg.Models[j].Platform - }) - for j := range pg.Models { - pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo) - } - out = append(out, *pg) - } - - sort.SliceStable(out, func(i, j int) bool { - if out[i].RateMultiplier != out[j].RateMultiplier { - return out[i].RateMultiplier < out[j].RateMultiplier - } - return out[i].Name < out[j].Name - }) - return out, nil -} - -// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致: -// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。 -// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参 -// (渠道定价指针指向缓存共享数据)。 -func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing { - if p == nil || g == nil || p.BillingMode != BillingModeImage { - return p - } - if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil { - return p - } - channelTierPrice := func(label string) *float64 { - for i := range p.Intervals { - if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil { - return p.Intervals[i].PerRequestPrice - } - } - return p.PerRequestPrice - } - tiers := []struct { - label string - groupPrice *float64 - }{ - {"1K", g.ImagePrice1K}, - {"2K", g.ImagePrice2K}, - {"4K", g.ImagePrice4K}, - } - clone := *p - clone.Intervals = make([]PricingInterval, 0, len(tiers)) - for i, t := range tiers { - price := t.groupPrice - if price == nil { - price = channelTierPrice(t.label) - } - if price == nil { - continue - } - v := *price - clone.Intervals = append(clone.Intervals, PricingInterval{ - TierLabel: t.label, - PerRequestPrice: &v, - SortOrder: i, - }) - } - return &clone -} - -// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。 -// pricingService 为 nil(测试场景)或查不到时返回 nil。 -func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing { - if s.pricingService == nil { - return nil - } - if cached, ok := memo[modelName]; ok { - return cached - } - var result *PlazaOfficialPricing - if lp := s.pricingService.GetModelPricing(modelName); lp != nil && !lp.TokenPricingAbsent { - result = &PlazaOfficialPricing{ - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - } - if result.InputPrice == nil && result.OutputPrice == nil && - result.CacheWritePrice == nil && result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil { - result = nil - } - } - memo[modelName] = result - return result -} diff --git a/backend/internal/service/channel_pricing_multipliers_test.go b/backend/internal/service/channel_pricing_multipliers_test.go new file mode 100644 index 0000000000..5b38fddec2 --- /dev/null +++ b/backend/internal/service/channel_pricing_multipliers_test.go @@ -0,0 +1,288 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func pricingMultiplier(value float64) *float64 { return &value } + +func TestConfiguredServiceTierMultiplier(t *testing.T) { + tests := []struct { + name string + serviceTier string + pricing *ModelPricing + want float64 + }{ + {name: "gpt-5.5 fast", serviceTier: "fast", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2.5)}, want: 2.5}, + {name: "priority alias", serviceTier: "priority", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2)}, want: 2}, + {name: "flex configured", serviceTier: "flex", pricing: &ModelPricing{FlexMultiplier: pricingMultiplier(0.4)}, want: 0.4}, + {name: "legacy fast default", serviceTier: "fast", pricing: &ModelPricing{}, want: 2}, + {name: "legacy flex default", serviceTier: "flex", pricing: &ModelPricing{}, want: 0.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.InDelta(t, tt.want, configuredServiceTierMultiplier(tt.serviceTier, tt.pricing), 1e-12) + }) + } +} + +func TestConfiguredServiceTierMultiplierAppliesToEveryTokenComponent(t *testing.T) { + pricing := &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + CacheCreationPricePerToken: 6.25e-6, + CacheReadPricePerToken: 0.5e-6, + FastMultiplier: pricingMultiplier(2.5), + FlexMultiplier: pricingMultiplier(0.5), + } + tokens := UsageTokens{ + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + CacheCreationTokens: 1_000_000, + CacheReadTokens: 1_000_000, + } + service := &BillingService{} + + fast := service.computeTokenBreakdown(pricing, tokens, 1, "fast", false) + require.InDelta(t, 12.5, fast.InputCost, 1e-12) + require.InDelta(t, 75, fast.OutputCost, 1e-12) + require.InDelta(t, 15.625, fast.CacheCreationCost, 1e-12) + require.InDelta(t, 1.25, fast.CacheReadCost, 1e-12) + + flex := service.computeTokenBreakdown(pricing, tokens, 1, "flex", false) + require.InDelta(t, 2.5, flex.InputCost, 1e-12) + require.InDelta(t, 15, flex.OutputCost, 1e-12) + require.InDelta(t, 3.125, flex.CacheCreationCost, 1e-12) + require.InDelta(t, 0.25, flex.CacheReadCost, 1e-12) +} + +func TestChannelOverridePreservesCatalogFastRatioByDefault(t *testing.T) { + pricing := &ModelPricing{ + InputPricePerToken: 2, + InputPricePerTokenPriority: 4, + OutputPricePerToken: 6, + OutputPricePerTokenPriority: 12, + } + applyChannelTokenPriceOverrides(pricing, &ChannelModelPricing{ + InputPrice: pricingMultiplier(3), + OutputPrice: pricingMultiplier(9), + }) + + require.InDelta(t, 3, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 9, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 18, pricing.OutputPricePerTokenPriority, 1e-12) +} + +func TestAnthropicFastUsesDefaultMultiplierWithoutCatalogTier(t *testing.T) { + pricing := &ModelPricing{InputPricePerToken: 5e-6, OutputPricePerToken: 25e-6} + cost := (&BillingService{}).computeTokenBreakdown(pricing, UsageTokens{ + InputTokens: 1_000_000, OutputTokens: 1_000_000, + }, 1, "fast", false) + + require.InDelta(t, 10, cost.InputCost, 1e-12) + require.InDelta(t, 50, cost.OutputCost, 1e-12) +} + +func TestBuiltInModelFastDefaults(t *testing.T) { + service := &BillingService{fallbackPrices: make(map[string]*ModelPricing)} + service.initFallbackPricing() + + for _, tt := range []struct { + model string + want float64 + }{ + {model: "gpt-5.5", want: 2.5}, + {model: "claude-opus-4.8", want: 2}, + {model: "claude-opus-5", want: 2}, + } { + pricing := service.fallbackPrices[tt.model] + require.NotNil(t, pricing) + require.InDelta(t, tt.want, pricing.InputPricePerTokenPriority/pricing.InputPricePerToken, 1e-12) + require.InDelta(t, tt.want, pricing.OutputPricePerTokenPriority/pricing.OutputPricePerToken, 1e-12) + } +} + +func TestIntervalMultipliersApplyToChannelBase(t *testing.T) { + base := &ModelPricing{ + InputPricePerToken: 5, + OutputPricePerToken: 30, + CacheCreationPricePerToken: 6.25, + CacheCreation5mPrice: 6.25, + CacheCreation1hPrice: 6.25, + CacheReadPricePerToken: 0.5, + FastMultiplier: pricingMultiplier(2), + FlexMultiplier: pricingMultiplier(0.5), + } + resolved := &ResolvedPricing{ + BasePricing: base, + Intervals: []PricingInterval{{ + MinTokens: 272000, + InputMultiplier: pricingMultiplier(2), + OutputMultiplier: pricingMultiplier(1.5), + CacheWriteMultiplier: pricingMultiplier(2), + CacheReadMultiplier: pricingMultiplier(2), + }}, + } + + pricing := (&ModelPricingResolver{}).GetIntervalPricing(resolved, 272001) + require.InDelta(t, 10, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 45, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 12.5, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 1, pricing.CacheReadPricePerToken, 1e-12) + require.Same(t, base, (&ModelPricingResolver{}).GetIntervalPricing(resolved, 272000)) +} + +func TestIntervalExplicitPriceTakesPrecedenceOverMultiplier(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{ + InputPrice: pricingMultiplier(7), + InputMultiplier: pricingMultiplier(2), + }, &ModelPricing{InputPricePerToken: 5}, nil) + + require.InDelta(t, 7, pricing.InputPricePerToken, 1e-12) +} + +func TestIntervalPricePreservesDefaultFastRatio(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{ + InputPrice: pricingMultiplier(7), + }, &ModelPricing{ + InputPricePerToken: 5, + InputPricePerTokenPriority: 10, + }, nil) + + require.InDelta(t, 7, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 14, pricing.InputPricePerTokenPriority, 1e-12) +} + +func TestAnthropicSpeedServiceTier(t *testing.T) { + account := &Account{Platform: PlatformAnthropic} + + for _, model := range []string{"claude-opus-5", "claude-opus-4-8", "claude-opus-4.8"} { + tier := anthropicSpeedServiceTier(account, "fast", model) + require.NotNil(t, tier, "model %s should bill as fast", model) + require.Equal(t, "fast", *tier) + } + + require.Nil(t, anthropicSpeedServiceTier(&Account{Platform: PlatformOpenAI}, "fast", "claude-opus-5")) + require.Nil(t, anthropicSpeedServiceTier(account, "standard", "claude-opus-5")) +} + +// fast mode 不存在于这些模型/承载上,即便客户端传了 speed=fast 也不能计 2x。 +func TestAnthropicSpeedServiceTierRejectsUnsupportedTargets(t *testing.T) { + account := &Account{Platform: PlatformAnthropic} + + for _, model := range []string{ + "claude-opus-4-7", // fast mode 已被移除 + "claude-opus-4-6", // + "claude-opus-4-5", // 不能被 "opus-5" 规则误判 + "claude-sonnet-5", // 非 Opus + "claude-haiku-4-5", // + "", // + } { + require.Nil(t, anthropicSpeedServiceTier(account, "fast", model), + "model %q must not bill as fast", model) + } + + bedrock := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock} + require.Nil(t, anthropicSpeedServiceTier(bedrock, "fast", "claude-opus-5")) +} + +func TestAnthropicSpeedModelPrefersMappedUpstreamModel(t *testing.T) { + parsed := &ParsedRequest{Model: "claude-opus-5"} + require.Equal(t, "claude-opus-4-7", anthropicSpeedModel(parsed, &ForwardResult{ + UpstreamModel: "claude-opus-4-7", + })) + require.Equal(t, "claude-opus-5", anthropicSpeedModel(parsed, &ForwardResult{})) +} + +func TestMultiplierOnlyIntervalIsValid(t *testing.T) { + require.NoError(t, ValidateIntervals([]PricingInterval{{ + MinTokens: 199999, + InputMultiplier: pricingMultiplier(2), + }}, BillingModeToken)) + require.NoError(t, checkIntervalsHavePrices(ChannelModelPricing{ + Models: []string{"grok-4.6"}, + Intervals: []PricingInterval{{ + MinTokens: 199999, + InputMultiplier: pricingMultiplier(2), + }}, + })) +} + +func TestChannelMultipliersMustBePositive(t *testing.T) { + zero := 0.0 + require.Error(t, checkPricesNotNegative(ChannelModelPricing{FastMultiplier: &zero})) + require.Error(t, checkPricesNotNegative(ChannelModelPricing{FlexMultiplier: &zero})) + require.Error(t, ValidateIntervals([]PricingInterval{{ + MinTokens: 100, + InputMultiplier: &zero, + }}, BillingModeToken)) +} + +func TestCalculateTokenCostContextTierEnablement(t *testing.T) { + base := &ModelPricing{InputPricePerToken: 1e-6} + resolved := &ResolvedPricing{ + BasePricing: base, + Intervals: []PricingInterval{{ + MinTokens: 100, + InputMultiplier: pricingMultiplier(2), + }}, + } + resolver := &ModelPricingResolver{} + service := &BillingService{} + tokens := UsageTokens{InputTokens: 200} + + t.Run("group disabled uses base tier", func(t *testing.T) { + resolved.longContextPricingEnabled = false + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + require.InDelta(t, 200e-6, cost.TotalCost, 1e-12) + }) + + t.Run("group enabled uses interval", func(t *testing.T) { + resolved.longContextPricingEnabled = true + accountDisabled := false + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + LongContextBillingEnabled: &accountDisabled, + }) + require.NoError(t, err) + require.InDelta(t, 400e-6, cost.TotalCost, 1e-12) + }) + + t.Run("account enabled overrides disabled group", func(t *testing.T) { + resolved.longContextPricingEnabled = false + accountEnabled := true + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + LongContextBillingEnabled: &accountEnabled, + }) + require.NoError(t, err) + require.InDelta(t, 400e-6, cost.TotalCost, 1e-12) + }) +} + +func TestCalculateTokenCostCombinesIntervalAndFastMultiplier(t *testing.T) { + resolved := &ResolvedPricing{ + BasePricing: &ModelPricing{ + InputPricePerToken: 1e-6, + FastMultiplier: pricingMultiplier(2.5), + }, + Intervals: []PricingInterval{{ + MinTokens: 100, + InputMultiplier: pricingMultiplier(2), + }}, + longContextPricingEnabled: true, + } + cost, err := (&BillingService{}).calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: UsageTokens{InputTokens: 200}, RateMultiplier: 1, + ServiceTier: "fast", Resolver: &ModelPricingResolver{}, + }) + require.NoError(t, err) + require.InDelta(t, 1e-3, cost.TotalCost, 1e-12) +} diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 3a8c5556ad..b3fe7dc63a 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -357,7 +357,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool { // fallback used before a request target has been resolved. func matchingPlatforms(groupPlatform string) []string { if groupPlatform == PlatformComposite { - return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} + return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} } return []string{groupPlatform} } @@ -646,7 +646,47 @@ func validatePricingEntries(pricing []ChannelModelPricing) error { if err := validatePricingIntervals(pricing); err != nil { return err } - return validatePricingBillingMode(pricing) + if err := validatePricingBillingMode(pricing); err != nil { + return err + } + return validatePricingTimePricing(pricing) +} + +func validatePricingTimePricing(pricing []ChannelModelPricing) error { + for i := range pricing { + config := pricing[i].TimePricing + if config == nil { + continue + } + if len(config.Periods) == 0 { + pricing[i].TimePricing = nil + continue + } + mode := pricing[i].BillingMode + if mode != "" && mode != BillingModeToken { + return infraerrors.BadRequest("TIME_PRICING_UNSUPPORTED_MODE", "time pricing only supports token billing mode") + } + if err := validateChannelTimePricing(config); err != nil { + return infraerrors.BadRequest("INVALID_TIME_PRICING", fmt.Sprintf( + "invalid time pricing for platform '%s' models %v: %v", pricing[i].Platform, pricing[i].Models, err)) + } + } + return nil +} + +func validateAccountStatsPricingRules(rules []AccountStatsPricingRule) error { + for i := range rules { + for _, pricing := range rules[i].Pricing { + if pricing.TimePricing != nil && len(pricing.TimePricing.Periods) > 0 { + return fmt.Errorf("account stats pricing rule #%d: %w", i+1, + infraerrors.BadRequest("ACCOUNT_STATS_TIME_PRICING_UNSUPPORTED", "account stats pricing does not support time pricing")) + } + } + if err := validatePricingEntries(rules[i].Pricing); err != nil { + return fmt.Errorf("account stats pricing rule #%d: %w", i+1, err) + } + } + return nil } // validatePricingBillingMode 校验计费模式配置:按次/图片模式必须配价格或区间,所有价格字段不能为负,区间至少有一个价格字段。 @@ -695,6 +735,17 @@ func checkPricesNotNegative(p ChannelModelPricing) error { return infraerrors.BadRequest("NEGATIVE_PRICE", fmt.Sprintf("%s must be >= 0", c.field)) } } + for _, c := range []struct { + field string + val *float64 + }{ + {"fast_multiplier", p.FastMultiplier}, + {"flex_multiplier", p.FlexMultiplier}, + } { + if c.val != nil && *c.val <= 0 { + return infraerrors.BadRequest("INVALID_MULTIPLIER", fmt.Sprintf("%s must be > 0", c.field)) + } + } return nil } @@ -702,7 +753,9 @@ func checkIntervalsHavePrices(p ChannelModelPricing) error { for _, iv := range p.Intervals { if iv.InputPrice == nil && iv.OutputPrice == nil && iv.CacheWritePrice == nil && iv.CacheReadPrice == nil && - iv.PerRequestPrice == nil { + iv.PerRequestPrice == nil && iv.InputMultiplier == nil && + iv.OutputMultiplier == nil && iv.CacheWriteMultiplier == nil && + iv.CacheReadMultiplier == nil { return infraerrors.BadRequest( "INTERVAL_MISSING_PRICE", fmt.Sprintf("interval [%d, %s] has no price fields set for model %v", @@ -755,10 +808,8 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { return nil, err } - for i, rule := range channel.AccountStatsPricingRules { - if err := validatePricingEntries(rule.Pricing); err != nil { - return nil, fmt.Errorf("account stats pricing rule #%d: %w", i+1, err) - } + if err := validateAccountStatsPricingRules(channel.AccountStatsPricingRules); err != nil { + return nil, err } if err := s.repo.Create(ctx, channel); err != nil { @@ -799,10 +850,8 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { return nil, err } - for i, rule := range channel.AccountStatsPricingRules { - if err := validatePricingEntries(rule.Pricing); err != nil { - return nil, fmt.Errorf("account stats pricing rule #%d: %w", i+1, err) - } + if err := validateAccountStatsPricingRules(channel.AccountStatsPricingRules); err != nil { + return nil, err } oldGroupIDs := s.getOldGroupIDs(ctx, id) diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index f56f61e917..e443b80b59 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -5,8 +5,10 @@ package service import ( "context" "errors" + "net/http" "testing" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -2039,6 +2041,9 @@ func TestIsPlatformPricingMatch(t *testing.T) { {"gemini does NOT match anthropic", PlatformGemini, PlatformAnthropic, false}, {"composite matches openai pricing", PlatformComposite, PlatformOpenAI, true}, {"composite matches gemini pricing", PlatformComposite, PlatformGemini, true}, + {"composite matches kimi pricing", PlatformComposite, PlatformKimi, true}, + {"composite matches zhipu pricing", PlatformComposite, PlatformZhipu, true}, + {"composite matches deepseek pricing", PlatformComposite, PlatformDeepseek, true}, {"empty string matches nothing", "", PlatformAnthropic, false}, {"empty string matches empty", "", "", true}, } @@ -2064,7 +2069,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}}, } for _, tt := range tests { @@ -2460,6 +2465,66 @@ func TestValidatePricingBillingMode(t *testing.T) { } } +func validTimePricingForTest() *ChannelTimePricing { + return &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "09:00", EndTime: "12:00", Multiplier: 2}, + }} +} + +func TestValidatePricingTimePricing(t *testing.T) { + token := []ChannelModelPricing{{BillingMode: BillingModeToken, TimePricing: validTimePricingForTest()}} + require.NoError(t, validatePricingTimePricing(token)) + + implicitToken := []ChannelModelPricing{{TimePricing: validTimePricingForTest()}} + require.NoError(t, validatePricingTimePricing(implicitToken)) + + image := []ChannelModelPricing{{BillingMode: BillingModeImage, TimePricing: validTimePricingForTest()}} + modeErr := infraerrors.FromError(validatePricingTimePricing(image)) + require.Equal(t, int32(http.StatusBadRequest), modeErr.Code) + require.Equal(t, "TIME_PRICING_UNSUPPORTED_MODE", modeErr.Reason) + + invalid := []ChannelModelPricing{{ + Platform: PlatformOpenAI, + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: &ChannelTimePricing{Timezone: "UTC+8", Periods: validTimePricingForTest().Periods}, + }} + invalidErr := infraerrors.FromError(validatePricingTimePricing(invalid)) + require.Equal(t, int32(http.StatusBadRequest), invalidErr.Code) + require.Equal(t, "INVALID_TIME_PRICING", invalidErr.Reason) + require.Contains(t, invalidErr.Message, "platform 'openai'") + require.Contains(t, invalidErr.Message, "models [gpt-5]") + + invalidMultiplier := []ChannelModelPricing{{ + Platform: PlatformOpenAI, + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{{ + StartTime: "09:00", EndTime: "12:00", Multiplier: 1e-12, + }}}, + }} + invalidMultiplierRawErr := validatePricingTimePricing(invalidMultiplier) + require.Error(t, invalidMultiplierRawErr) + invalidMultiplierErr := infraerrors.FromError(invalidMultiplierRawErr) + require.Equal(t, int32(http.StatusBadRequest), invalidMultiplierErr.Code) + require.Equal(t, "INVALID_TIME_PRICING", invalidMultiplierErr.Reason) + + empty := []ChannelModelPricing{{BillingMode: BillingModeToken, TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai"}}} + require.NoError(t, validatePricingTimePricing(empty)) + require.Nil(t, empty[0].TimePricing) +} + +func TestValidateAccountStatsPricingRulesRejectsTimePricing(t *testing.T) { + rules := []AccountStatsPricingRule{{Pricing: []ChannelModelPricing{{ + BillingMode: BillingModeToken, + TimePricing: validTimePricingForTest(), + }}}} + + appErr := infraerrors.FromError(validateAccountStatsPricingRules(rules)) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "ACCOUNT_STATS_TIME_PRICING_UNSUPPORTED", appErr.Reason) +} + // --------------------------------------------------------------------------- // 12. Antigravity wildcard mapping isolation // --------------------------------------------------------------------------- diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 2f371f8a1c..42c2c46448 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -197,6 +197,15 @@ func TestChannelModelPricingClone(t *testing.T) { Intervals: []PricingInterval{ {MinTokens: 0, TierLabel: "tier1"}, }, + TimePricing: &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, + Periods: []ChannelTimePricingPeriod{{ + StartTime: "09:00", + EndTime: "12:00", + Multiplier: 2, + }}, + }, } cloned := original.Clone() @@ -207,6 +216,15 @@ func TestChannelModelPricingClone(t *testing.T) { cloned.Intervals[0].TierLabel = "hacked" require.Equal(t, "tier1", original.Intervals[0].TierLabel) + + cloned.TimePricing.Timezone = "America/New_York" + cloned.TimePricing.WeekdaysOnly = false + cloned.TimePricing.Periods[0].StartTime = "10:00" + cloned.TimePricing.Periods[0].Multiplier = 3 + require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone) + require.True(t, original.TimePricing.WeekdaysOnly) + require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime) + require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier) } // --- BillingMode.IsValid --- @@ -513,7 +531,6 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) { } } - func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) { ch := &Channel{ ModelMapping: map[string]map[string]string{ diff --git a/backend/internal/service/cn_provider_balance_check_service.go b/backend/internal/service/cn_provider_balance_check_service.go new file mode 100644 index 0000000000..d5ddd6712e --- /dev/null +++ b/backend/internal/service/cn_provider_balance_check_service.go @@ -0,0 +1,279 @@ +package service + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +// cnQuotaProber 抽象额度探测(*CNProviderQuotaService 实现,测试可替换)。 +type cnQuotaProber interface { + QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) +} + +// cnQuotaProbeConcurrency 周期任务并发探测额度账号的并发度。 +const cnQuotaProbeConcurrency = 4 + +// CNProviderBalanceCheckService 周期性探测国产供应商账号: +// - payg(按量付费):余额低于阈值则临时停调,恢复则清除(仅清除本服务写入的停调); +// - coding plan:调用 CNProviderQuotaService 探测 5h/weekly 滚动窗口并落 extra 快照, +// 调度阈值评估(cnProviderThresholdCandidates)据此自动停调/恢复。 +// +// 克隆自 AccountExpiryService 的 Start/Stop/runOnce + ticker 骨架。 +// 余额探测仅覆盖有公开余额端点的 kimi / deepseek;智谱无余额端点,仅靠响应式 429/402。 +// 额度探测覆盖 kimi / zhipu 的 coding plan 账号(deepseek 无 coding 套餐)。 +type CNProviderBalanceCheckService struct { + accountRepo AccountRepository + balanceService *CNProviderBalanceService + quotaService cnQuotaProber + cfg *config.Config + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +// NewCNProviderBalanceCheckService 构造周期余额/额度检测服务。 +// interval <= 0 时 Start() 直接返回(不启动),便于通过配置关闭。 +func NewCNProviderBalanceCheckService( + accountRepo AccountRepository, + balanceService *CNProviderBalanceService, + quotaService *CNProviderQuotaService, + cfg *config.Config, + interval time.Duration, +) *CNProviderBalanceCheckService { + return &CNProviderBalanceCheckService{ + accountRepo: accountRepo, + balanceService: balanceService, + quotaService: quotaService, + cfg: cfg, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *CNProviderBalanceCheckService) Start() { + if s == nil || s.accountRepo == nil || s.balanceService == nil || s.cfg == nil { + return + } + if !s.cfg.Gateway.CNProviders.BalanceCheckEnabled { + return + } + if s.interval <= 0 { + return + } + log.Printf("[CNBalance] started (interval=%s threshold=%.2f)", s.interval, s.cfg.Gateway.CNProviders.BalanceThreshold) + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + // 启动后先等待一个周期再首次探测,避免与进程启动峰重叠。 + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *CNProviderBalanceCheckService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *CNProviderBalanceCheckService) runOnce() { + // 收集 coding 探测目标(kimi/deepseek + 智谱)与 payg 检查队列。 + // coding 探测统一在收集完成后按 4 并发执行:单账号探测 15-20s,串行 × + // 多账号会耗尽整体预算(120s 上限),排在后面的账号快照会饥饿, + // 连锁影响阈值停调的新鲜度判定。 + type quotaTarget struct { + id int64 + platform string + } + var quotaTargets []quotaTarget + var paygTargets []*Account + collect := func(platform string, accounts []Account) { + for i := range accounts { + account := &accounts[i] + if !account.IsActive() { + continue + } + // coding 账号:探测滚动窗口并落快照(不要求 Schedulable——已被 + // 阈值停调的账号也需要新鲜快照决定是否续停)。 + if account.IsCodingPlan() { + quotaTargets = append(quotaTargets, quotaTarget{id: account.ID, platform: account.Platform}) + continue + } + // payg 余额探测仅 kimi/deepseek(智谱无公开余额端点,payg 账号 + // 依赖响应式 402/429 处理)。 + if platform != PlatformZhipu && account.Schedulable { + paygTargets = append(paygTargets, account) + } + } + } + for _, platform := range s.platforms() { + accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform) + if err != nil { + log.Printf("[CNBalance] list %s accounts failed: %v", platform, err) + continue + } + collect(platform, accounts) + } + // 智谱无余额端点,仅进额度探测。 + if s.quotaService != nil { + accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformZhipu) + if err != nil { + log.Printf("[CNBalance] list %s accounts failed: %v", PlatformZhipu, err) + } else { + collect(PlatformZhipu, accounts) + } + } + + // 预算按工作量放大:4 并发 × 15s/批 + payg 每账号 5s,下限 30s 上限 300s。 + batches := (len(quotaTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency + timeout := 30*time.Second + time.Duration(batches)*15*time.Second + time.Duration(len(paygTargets))*5*time.Second + if timeout > 300*time.Second { + timeout = 300 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + threshold := s.cfg.Gateway.CNProviders.BalanceThreshold + paused, cleared := 0, 0 + for _, account := range paygTargets { + switch s.checkOne(ctx, account, threshold) { + case cnBalancePaused: + paused++ + case cnBalanceCleared: + cleared++ + } + } + + if len(quotaTargets) > 0 && s.quotaService != nil { + sem := make(chan struct{}, cnQuotaProbeConcurrency) + var wg sync.WaitGroup + for _, target := range quotaTargets { + wg.Add(1) + go func(t quotaTarget) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + s.probeQuota(ctx, t.id, t.platform) + }(target) + } + wg.Wait() + } + + if paused > 0 || cleared > 0 { + log.Printf("[CNBalance] paused=%d cleared=%d (threshold=%.2f)", paused, cleared, threshold) + } +} + +// probeQuota 探测单个 coding plan 账号的滚动窗口用量并落 extra 快照。 +// 不在此处做停调/恢复决策:调度阈值评估读取快照统一判定(含暂停账号的续停)。 +func (s *CNProviderBalanceCheckService) probeQuota(ctx context.Context, accountID int64, platform string) { + if s.quotaService == nil { + return + } + result, err := s.quotaService.QueryUsage(ctx, accountID) + if err != nil { + log.Printf("[CNBalance] quota probe account %d (%s) failed: %v", accountID, platform, err) + return + } + if result != nil && !result.Success && result.Error != "" { + log.Printf("[CNBalance] quota probe account %d (%s) error: %s", accountID, platform, result.Error) + } +} + +type cnBalanceCheckOutcome int + +const ( + cnBalanceNoChange cnBalanceCheckOutcome = iota + cnBalancePaused + cnBalanceCleared +) + +// checkOne 探测单账号余额并决定停调/恢复。探测失败时不动现状(避免瞬时网络抖动 +// 误解除或误停调)。 +func (s *CNProviderBalanceCheckService) checkOne(ctx context.Context, account *Account, threshold float64) cnBalanceCheckOutcome { + result, err := s.balanceService.QueryBalance(ctx, account.ID) + if err != nil || result == nil || !result.Success { + return cnBalanceNoChange + } + + // 双币种(deepseek CNY+USD)任一币种余额达标即可继续调度;仅当全部低于 + // 阈值(或不可用)才停调。 + low := !result.Available || allCNBalancesBelowThreshold(result, threshold) + if low { + // 已被(任何来源)停调时不覆盖其 reason。 + if !account.IsSchedulable() { + return cnBalanceNoChange + } + reason := cnBalanceLowReason(fmt.Sprintf("余额 %.4g %s 低于阈值 %.2f", result.Balance, result.Currency, threshold)) + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, time.Now().Add(s.cooldown()), reason); err != nil { + log.Printf("[CNBalance] pause account %d failed: %v", account.ID, err) + return cnBalanceNoChange + } + log.Printf("[CNBalance] paused account %d (%s): balance=%.4g %s", account.ID, account.Platform, result.Balance, result.Currency) + return cnBalancePaused + } + + // 余额健康:仅清除「本服务写入」的临时停调(reason 前缀匹配),不触碰其他子系统。 + if account.TempUnschedulableUntil != nil && strings.HasPrefix(account.TempUnschedulableReason, cnBalanceLowReasonPrefix) { + if err := s.accountRepo.ClearTempUnschedulable(ctx, account.ID); err != nil { + log.Printf("[CNBalance] clear account %d failed: %v", account.ID, err) + return cnBalanceNoChange + } + log.Printf("[CNBalance] reactivated account %d (%s): balance=%.4g %s", account.ID, account.Platform, result.Balance, result.Currency) + return cnBalanceCleared + } + return cnBalanceNoChange +} + +func (s *CNProviderBalanceCheckService) platforms() []string { + return []string{PlatformKimi, PlatformDeepseek} +} + +// allCNBalancesBelowThreshold 判断全部币种余额是否均低于阈值。 +// 无明细时退回主币种判定(与旧行为一致)。 +func allCNBalancesBelowThreshold(result *CNProviderBalanceResult, threshold float64) bool { + if len(result.Balances) == 0 { + return result.Balance < threshold + } + for _, entry := range result.Balances { + if entry.Balance >= threshold { + return false + } + } + return true +} + +// cooldown 返回临时停调持续时长(= 2× 检测周期),与响应式 402/429 路径一致。 +func (s *CNProviderBalanceCheckService) cooldown() time.Duration { + minutes := 10 + if s.cfg != nil { + if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 { + minutes = cfgMin + } + } + cooldown := time.Duration(minutes) * time.Minute * 2 + if cooldown < time.Minute { + cooldown = 10 * time.Minute + } + return cooldown +} diff --git a/backend/internal/service/cn_provider_balance_check_service_test.go b/backend/internal/service/cn_provider_balance_check_service_test.go new file mode 100644 index 0000000000..ddd59d5dcd --- /dev/null +++ b/backend/internal/service/cn_provider_balance_check_service_test.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// 周期任务对 coding plan 账号的额度探测行为(runOnce 集成路径): +// - kimi coding 账号(含已被阈值停调的)→ 额度探测被调用; +// - 智谱 coding 账号 → 额度探测被调用(智谱不进 kimi/deepseek 余额循环); +// - payg 账号不经过额度探测(走余额路径,本测试不放 payg 账号避免真实网络); +// - 非激活账号完全跳过。 + +// fakeCNQuotaProber 需要并发安全:runOnce 以 cnQuotaProbeConcurrency 并发调用 QueryUsage。 +type fakeCNQuotaProber struct { + mu sync.Mutex + probed []int64 +} + +func (f *fakeCNQuotaProber) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.probed = append(f.probed, accountID) + return &CNProviderQuotaProbeResult{Success: true, Persisted: true}, nil +} + +type fakeCNCheckRepo struct { + AccountRepository + byPlatform map[string][]Account +} + +func (r *fakeCNCheckRepo) ListByPlatform(ctx context.Context, platform string) ([]Account, error) { + return r.byPlatform[platform], nil +} + +func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) { + kimiActive := Account{ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}} + // 已被阈值停调的 coding 账号也要刷新快照(决定是否续停)。 + kimiPaused := Account{ID: 2, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: false, + Credentials: map[string]any{"account_mode": "coding"}} + // 非激活账号跳过。 + kimiInactive := Account{ID: 3, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusDisabled, + Credentials: map[string]any{"account_mode": "coding"}} + zhipuCoding := Account{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}} + + repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{ + PlatformKimi: {kimiActive, kimiPaused, kimiInactive}, + PlatformZhipu: {zhipuCoding}, + }} + prober := &fakeCNQuotaProber{} + svc := &CNProviderBalanceCheckService{ + accountRepo: repo, + quotaService: prober, + cfg: &config.Config{}, + } + + svc.runOnce() + + require.ElementsMatch(t, []int64{1, 2, 4}, prober.probed) +} + +// runOnceZhipuQuota 在 quotaService 缺失时安全跳过(Start 门控不启动的老部署路径)。 +func TestCNProviderBalanceCheckRunOnceWithoutQuotaService(t *testing.T) { + repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{ + PlatformZhipu: {{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}}}, + }} + svc := &CNProviderBalanceCheckService{accountRepo: repo, cfg: &config.Config{}} + require.NotPanics(t, func() { svc.runOnce() }) +} + +// 双币种(deepseek CNY+USD)停调判定:任一币种达标即不停调,全部低于阈值才停; +// 无明细时退回主币种(兼容旧结果)。 +func TestAllCNBalancesBelowThreshold(t *testing.T) { + dualLow := &CNProviderBalanceResult{ + Balance: 1.0, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 1.0}, + {Currency: "USD", Balance: 0.5}, + }, + } + require.True(t, allCNBalancesBelowThreshold(dualLow, 5.0)) + + dualMixed := &CNProviderBalanceResult{ + Balance: 1.0, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 1.0}, + {Currency: "USD", Balance: 20.0}, + }, + } + require.False(t, allCNBalancesBelowThreshold(dualMixed, 5.0)) + + // 无明细:按主币种判定(旧行为)。 + singleLow := &CNProviderBalanceResult{Balance: 1.0, Currency: "CNY"} + require.True(t, allCNBalancesBelowThreshold(singleLow, 5.0)) + singleOK := &CNProviderBalanceResult{Balance: 10.0, Currency: "CNY"} + require.False(t, allCNBalancesBelowThreshold(singleOK, 5.0)) +} diff --git a/backend/internal/service/cn_provider_balance_service.go b/backend/internal/service/cn_provider_balance_service.go new file mode 100644 index 0000000000..c474683077 --- /dev/null +++ b/backend/internal/service/cn_provider_balance_service.go @@ -0,0 +1,305 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/tidwall/gjson" + "golang.org/x/sync/singleflight" +) + +// 国产供应商 payg(按量付费)账号余额探测服务。 +// +// 仅覆盖有公开余额端点的供应商: +// - Kimi/Moonshot:GET https://api.moonshot.cn/v1/users/me/balance (Bearer) → data.available_balance +// - DeepSeek: GET https://api.deepseek.com/user/balance (Bearer) → balance_infos[].total_balance + is_available +// +// 智谱(zhipu)无公开余额端点(OpenAPI 规格验证),仅靠响应式 429/402(见 +// ratelimit_cn_providers.go)。解析逻辑对齐 cc-switch services/balance.rs::query_deepseek。 +const ( + cnBalanceUpstreamTimeout = 15 * time.Second + cnBalanceMaxBodyBytes = 256 * 1024 + + // Extra 余额快照键后缀(加 provider 前缀)。 + cnBalanceExtraSuffixBalance = "balance" + cnBalanceExtraSuffixCurrency = "balance_currency" + cnBalanceExtraSuffixAvailable = "balance_available" // deepseek is_available 健康标记 + cnBalanceExtraSuffixUpdated = "balance_updated_at" + cnBalanceExtraSuffixBalances = "balances" // 多币种明细(deepseek USD+CNY) +) + +// CNProviderBalanceEntry 是单一币种的余额明细。 +type CNProviderBalanceEntry struct { + Currency string `json:"currency"` + Balance float64 `json:"balance"` +} + +// CNProviderBalanceResult 是余额探测的返回结构(管理端 + UI 消费)。 +type CNProviderBalanceResult struct { + Provider string `json:"provider"` + Success bool `json:"success"` + // Balance/Currency 为主币种(balance_infos 首条,兼容单币种消费方); + // 完整明细见 Balances(deepseek 双币种账号含 CNY + USD 两条)。 + Balance float64 `json:"balance"` + Currency string `json:"currency,omitempty"` + Balances []CNProviderBalanceEntry `json:"balances,omitempty"` + Available bool `json:"available"` // 健康标记(deepseek is_available;kimi 无此概念恒 true) + StatusCode int `json:"status_code,omitempty"` + FetchedAt int64 `json:"fetched_at"` + Persisted bool `json:"persisted"` + Error string `json:"error,omitempty"` +} + +// CNProviderBalanceService 探测 Kimi / DeepSeek payg 账号的账户余额。 +type CNProviderBalanceService struct { + accountRepo AccountRepository + proxyRepo ProxyRepository + httpUpstream HTTPUpstream + cfg *config.Config + flight singleflight.Group +} + +// NewCNProviderBalanceService 构造余额探测服务。 +func NewCNProviderBalanceService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderBalanceService { + return &CNProviderBalanceService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + httpUpstream: httpUpstream, + cfg: cfg, + } +} + +// QueryBalance 探测指定 payg 账号的余额并落 Extra 快照。 +func (s *CNProviderBalanceService) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { + account, err := s.loadPayGAccount(ctx, accountID) + if err != nil { + return nil, err + } + return s.QueryBalanceForAccount(ctx, account) +} + +// QueryBalanceForAccount 探测已加载账号(配额监控 fetcher / 周期余额检测复用, +// 避免二次 GetByID)。singleflight key 与 QueryBalance 相同,按账号 ID 合并。 +func (s *CNProviderBalanceService) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { + if s == nil || s.accountRepo == nil || s.httpUpstream == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_NOT_CONFIGURED", "cn provider balance service is not configured") + } + if err := validatePayGAccount(account); err != nil { + return nil, err + } + key := "cn_balance:" + strconv.FormatInt(account.ID, 10) + resultCh := s.flight.DoChan(key, func() (any, error) { + probeCtx, cancel := context.WithTimeout(context.Background(), cnBalanceUpstreamTimeout+5*time.Second) + defer cancel() + return s.queryBalanceForAccount(probeCtx, account) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case flightResult := <-resultCh: + if flightResult.Err != nil { + return nil, flightResult.Err + } + result, ok := flightResult.Val.(*CNProviderBalanceResult) + if !ok || result == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_PROBE_RESULT_INVALID", "invalid cn provider balance probe result") + } + cloned := *result + return &cloned, nil + } +} + +func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { + provider := account.Platform + if provider != PlatformKimi && provider != PlatformDeepseek { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint") + } + + apiKey := strings.TrimSpace(account.GetCNAPIKey()) + if apiKey == "" { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty") + } + + targetURL := cnBalanceURL(account) + // 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验): + // DeepSeek 端点由账号 base_url 衍生,不得把 API key 发往策略外主机。 + validatedURL, err := cnValidateProbeURL(s.cfg, targetURL) + if err != nil { + return nil, infraerrors.New(http.StatusForbidden, "CN_BALANCE_URL_REJECTED", err.Error()) + } + targetURL = validatedURL + proxyURL := s.resolveProxyURL(ctx, account) + callCtx, cancel := context.WithTimeout(ctx, cnBalanceUpstreamTimeout) + defer cancel() + req, err := http.NewRequestWithContext(callCtx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "CN_BALANCE_REQUEST_BUILD_FAILED", "build request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1)) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "CN_BALANCE_REQUEST_FAILED", "upstream request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, cnBalanceMaxBodyBytes)) + + now := time.Now().UTC() + result := &CNProviderBalanceResult{ + Provider: provider, + FetchedAt: now.Unix(), + StatusCode: resp.StatusCode, + Available: true, + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + result.Error = fmt.Sprintf("Authentication failed (HTTP %d)", resp.StatusCode) + return result, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, truncate(strings.TrimSpace(string(bodyBytes)), 240)) + return result, nil + } + + var entries []CNProviderBalanceEntry + available := true + switch provider { + case PlatformKimi: + // Moonshot:code==0 成功;data.available_balance(number),单币种 CNY。 + balance, _ := cnParseF64(gjson.GetBytes(bodyBytes, "data.available_balance").Value()) + entries = append(entries, CNProviderBalanceEntry{Currency: "CNY", Balance: balance}) + case PlatformDeepseek: + // is_available 缺省视为 true(健康);显式存在时取其值。 + if v := gjson.GetBytes(bodyBytes, "is_available"); v.Exists() { + available = v.Bool() + } + // balance_infos 逐条解析:双币种账号同时返回 CNY + USD(数组顺序即 + // 主次序,首条为主币种)。 + balanceInfos := gjson.GetBytes(bodyBytes, "balance_infos") + if !balanceInfos.Exists() || !balanceInfos.IsArray() { + result.Error = "Invalid balance response: missing balance_infos" + return result, nil + } + balanceInfos.ForEach(func(_, info gjson.Result) bool { + currency := strings.ToUpper(strings.TrimSpace(info.Get("currency").String())) + totalBalance := info.Get("total_balance") + balance, ok := cnParseF64(totalBalance.Value()) + if !totalBalance.Exists() || !ok { + return true + } + if currency == "" { + currency = "CNY" + } + entries = append(entries, CNProviderBalanceEntry{Currency: currency, Balance: balance}) + return true + }) + if len(entries) == 0 { + result.Error = "Invalid balance response: no valid balance entries" + return result, nil + } + } + result.Balances = entries + result.Balance = entries[0].Balance + result.Currency = entries[0].Currency + result.Available = available + result.Success = true + + balanceUpdates := make([]any, 0, len(entries)) + for _, entry := range entries { + balanceUpdates = append(balanceUpdates, map[string]any{ + "currency": entry.Currency, + "balance": entry.Balance, + }) + } + updates := map[string]any{ + cnExtraKey(provider, cnBalanceExtraSuffixBalance): result.Balance, + cnExtraKey(provider, cnBalanceExtraSuffixCurrency): result.Currency, + cnExtraKey(provider, cnBalanceExtraSuffixAvailable): available, + cnExtraKey(provider, cnBalanceExtraSuffixUpdated): now.Format(time.RFC3339), + cnExtraKey(provider, cnBalanceExtraSuffixBalances): balanceUpdates, + // 余额探测成功即清除响应式 402/429 写下的 balance_low 标记。 + cnExtraKey(provider, cnBalanceExtraSuffixLow): false, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { + slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", provider, "error", err) + } else { + result.Persisted = true + } + return result, nil +} + +// loadPayGAccount 加载 payg 模式的国产供应商账号(余额仅对 payg 有意义;coding 走额度)。 +func (s *CNProviderBalanceService) loadPayGAccount(ctx context.Context, accountID int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, infraerrors.Newf(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found: %v", err) + } + if err := validatePayGAccount(account); err != nil { + return nil, err + } + return account, nil +} + +// validatePayGAccount 加载后的非 DB 校验(ForAccount 入口同样复用, +// 保证直传 account 也不绕过平台/模式检查)。 +func validatePayGAccount(account *Account) error { + if account == nil { + return infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found") + } + if !account.IsCNProvider() { + return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account") + } + // coding 账号走额度探测,余额端点不适用。 + if account.IsCodingPlan() { + return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_CODING_PLAN", "coding plan account has no balance endpoint; use quota probe") + } + return nil +} + +func (s *CNProviderBalanceService) resolveProxyURL(ctx context.Context, account *Account) string { + if account == nil || account.ProxyID == nil { + return "" + } + if account.Proxy != nil { + return account.Proxy.URL() + } + if s != nil && s.proxyRepo != nil { + if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil { + account.Proxy = proxy + return proxy.URL() + } + } + return "" +} + +// cnBalanceURL 解析账号的余额端点。 +// +// - Kimi:固定 https://api.moonshot.cn/v1/users/me/balance(与 base_url 无关,Moonshot 仅此一处) +// - DeepSeek:基于 base_url 拼接 /user/balance(支持自定义域名) +func cnBalanceURL(account *Account) string { + switch account.Platform { + case PlatformKimi: + return "https://api.moonshot.cn/v1/users/me/balance" + case PlatformDeepseek: + // Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,余额探测需回退 + // 到 OpenAI 格式 base(协议感知)再拼接 /user/balance。 + return strings.TrimRight(account.GetOpenAIFormatBaseURL(), "/") + "/user/balance" + default: + return "" + } +} diff --git a/backend/internal/service/cn_provider_balance_service_test.go b/backend/internal/service/cn_provider_balance_service_test.go new file mode 100644 index 0000000000..9473bed1fb --- /dev/null +++ b/backend/internal/service/cn_provider_balance_service_test.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +type cnBalanceResponseUpstream struct { + statusCode int + body string +} + +func (u *cnBalanceResponseUpstream) Do( + _ *http.Request, + _ string, + _ int64, + _ int, +) (*http.Response, error) { + return &http.Response{ + StatusCode: u.statusCode, + Body: io.NopCloser(strings.NewReader(u.body)), + Header: make(http.Header), + }, nil +} + +func (u *cnBalanceResponseUpstream) DoWithTLS( + req *http.Request, + proxyURL string, + accountID int64, + accountConcurrency int, + _ *tlsfingerprint.Profile, +) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + +type cnBalanceProbeRepo struct { + AccountRepository + account *Account + extraWrites []map[string]any +} + +func (r *cnBalanceProbeRepo) GetByID(_ context.Context, _ int64) (*Account, error) { + return r.account, nil +} + +func (r *cnBalanceProbeRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { + r.extraWrites = append(r.extraWrites, updates) + return nil +} + +func newDeepSeekBalanceProbeAccount() *Account { + return &Account{ + ID: 42, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{ + "account_mode": AccountModePayG, + "api_key": "sk-test", + "base_url": "https://relay.example.com", + }, + } +} + +func TestCNProviderBalanceService_DeepSeekInvalidBalancePayloadDoesNotBecomeZero(t *testing.T) { + tests := []struct { + name string + body string + wantError string + }{ + { + name: "missing balance infos", + body: `{"data":{"models":["deepseek-v4-flash"]}}`, + wantError: "missing balance_infos", + }, + { + name: "empty balance infos", + body: `{"is_available":true,"balance_infos":[]}`, + wantError: "no valid balance entries", + }, + { + name: "invalid balance value", + body: `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"not-a-number"}]}`, + wantError: "no valid balance entries", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &cnBalanceProbeRepo{account: newDeepSeekBalanceProbeAccount()} + upstream := &cnBalanceResponseUpstream{statusCode: http.StatusOK, body: tt.body} + svc := NewCNProviderBalanceService(repo, nil, upstream, nil) + + result, err := svc.QueryBalance(context.Background(), repo.account.ID) + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.Success) + require.Contains(t, result.Error, tt.wantError) + require.Empty(t, result.Balances) + require.Empty(t, repo.extraWrites, "invalid relay balance payload must not persist a synthetic zero balance") + }) + } +} + +func TestCNProviderBalanceService_DeepSeekValidZeroBalanceRemainsSuccessful(t *testing.T) { + repo := &cnBalanceProbeRepo{account: newDeepSeekBalanceProbeAccount()} + upstream := &cnBalanceResponseUpstream{ + statusCode: http.StatusOK, + body: `{"is_available":false,"balance_infos":[{"currency":"CNY","total_balance":"0"}]}`, + } + svc := NewCNProviderBalanceService(repo, nil, upstream, nil) + + result, err := svc.QueryBalance(context.Background(), repo.account.ID) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Success) + require.False(t, result.Available) + require.Equal(t, "CNY", result.Currency) + require.Zero(t, result.Balance) + require.Len(t, result.Balances, 1) + require.Len(t, repo.extraWrites, 1, "a valid upstream zero balance must still be persisted") +} diff --git a/backend/internal/service/cn_provider_foraccount_test.go b/backend/internal/service/cn_provider_foraccount_test.go new file mode 100644 index 0000000000..664da505fb --- /dev/null +++ b/backend/internal/service/cn_provider_foraccount_test.go @@ -0,0 +1,125 @@ +package service + +// ForAccount 直传入口(P2-6)的校验回归测试: +// QueryUsageForAccount / QueryBalanceForAccount 接受已加载的 *Account, +// 但必须复用与 ID 入口相同的加载后校验——直传不能绕过平台/模式检查, +// 且校验在 singleflight 之前完成(无效账号不得发起任何上游请求)。 + +import ( + "context" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func codingAccount(platform string) *Account { + return &Account{ + ID: 1, Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": AccountModeCoding, "api_key": "sk-test"}, + } +} + +func paygAccount(platform string) *Account { + return &Account{ + ID: 2, Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": AccountModePayG, "api_key": "sk-test"}, + } +} + +func requireReason(t *testing.T, err error, reason string) { + t.Helper() + require.Error(t, err) + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.Equal(t, reason, appErr.Reason) +} + +func TestValidateCodingPlanAccount_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantReason string + }{ + {name: "nil", account: nil, wantReason: "CN_QUOTA_ACCOUNT_NOT_FOUND"}, + {name: "non cn provider", account: &Account{ID: 3, Platform: PlatformAnthropic}, wantReason: "CN_QUOTA_INVALID_PLATFORM"}, + {name: "payg has no quota endpoint", account: paygAccount(PlatformKimi), wantReason: "CN_QUOTA_NOT_CODING_PLAN"}, + {name: "kimi coding ok", account: codingAccount(PlatformKimi)}, + {name: "zhipu coding ok", account: codingAccount(PlatformZhipu)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCodingPlanAccount(tc.account) + if tc.wantReason == "" { + require.NoError(t, err) + return + } + requireReason(t, err, tc.wantReason) + }) + } +} + +func TestValidatePayGAccount_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantReason string + }{ + {name: "nil", account: nil, wantReason: "CN_BALANCE_ACCOUNT_NOT_FOUND"}, + {name: "non cn provider", account: &Account{ID: 3, Platform: PlatformAnthropic}, wantReason: "CN_BALANCE_INVALID_PLATFORM"}, + {name: "coding has no balance endpoint", account: codingAccount(PlatformKimi), wantReason: "CN_BALANCE_CODING_PLAN"}, + {name: "kimi payg ok", account: paygAccount(PlatformKimi)}, + {name: "deepseek payg ok", account: paygAccount(PlatformDeepseek)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validatePayGAccount(tc.account) + if tc.wantReason == "" { + require.NoError(t, err) + return + } + requireReason(t, err, tc.wantReason) + }) + } +} + +// 直传入口的校验在 singleflight/上游请求之前:无效账号必须零出站请求。 +func TestCNProviderQuotaService_QueryUsageForAccount_RejectsInvalidAccount(t *testing.T) { + repo := &fakeCNProbeAccountRepo{} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, nil) + + _, err := svc.QueryUsageForAccount(context.Background(), paygAccount(PlatformKimi)) + requireReason(t, err, "CN_QUOTA_NOT_CODING_PLAN") + require.Zero(t, upstream.calls) + + _, err = svc.QueryUsageForAccount(context.Background(), nil) + requireReason(t, err, "CN_QUOTA_ACCOUNT_NOT_FOUND") + require.Zero(t, upstream.calls) +} + +func TestCNProviderBalanceService_QueryBalanceForAccount_RejectsInvalidAccount(t *testing.T) { + repo := &fakeCNProbeAccountRepo{} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderBalanceService(repo, nil, upstream, nil) + + _, err := svc.QueryBalanceForAccount(context.Background(), codingAccount(PlatformKimi)) + requireReason(t, err, "CN_BALANCE_CODING_PLAN") + require.Zero(t, upstream.calls) + + _, err = svc.QueryBalanceForAccount(context.Background(), &Account{ID: 9, Platform: PlatformAnthropic}) + requireReason(t, err, "CN_BALANCE_INVALID_PLATFORM") + require.Zero(t, upstream.calls) +} + +// ID 入口与 ForAccount 入口对同一账号的行为一致(loadCodingPlanAccount 的 +// 加载后校验 = validateCodingPlanAccount;余额侧对称)。 +func TestCNProviderServices_IDEntryAppliesSameValidation(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: paygAccount(PlatformKimi)} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, nil) + + _, err := svc.QueryUsage(context.Background(), 2) + requireReason(t, err, "CN_QUOTA_NOT_CODING_PLAN") + require.Zero(t, upstream.calls) +} diff --git a/backend/internal/service/cn_provider_probe_url.go b/backend/internal/service/cn_provider_probe_url.go new file mode 100644 index 0000000000..95b0ff783f --- /dev/null +++ b/backend/internal/service/cn_provider_probe_url.go @@ -0,0 +1,48 @@ +package service + +// CN 供应商探测端点的出站 URL 安全策略校验(配额/余额探测共用)。 +// +// 背景(review B4):这两条探测路径会把账号 API key 发往 base_url 衍生端点, +// 此前完全绕过 security.url_allowlist——在加固部署里构成任意外发与内网探测 +// 面(本项目此前发生过账号测试 SSRF 生产事件)。与网关转发 +// (validateUpstreamBaseURL)、Grok 探测(grokOperatorPolicyValidator)一致, +// 探测发起前必须过同一套运营者策略。 + +import ( + "errors" + "fmt" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" +) + +// cnValidateProbeURL 按全局出站 URL 安全策略校验探测端点,返回规范化 URL。 +// 白名单开启时强制 UpstreamHosts(阻断私网与未列名主机);关闭时仅做格式 +// 校验(HTTP 允许与否跟随配置);cfg 为 nil 时退化为纯格式校验。 +func cnValidateProbeURL(cfg *config.Config, raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("probe url is required") + } + if cfg != nil && cfg.Security.URLAllowlist.Enabled { + normalized, err := urlvalidator.ValidateHTTPSURL(trimmed, urlvalidator.ValidationOptions{ + AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts, + }) + if err != nil { + return "", fmt.Errorf("probe target rejected by URL security policy: %w", err) + } + return normalized, nil + } + var allowInsecureHTTP bool + if cfg != nil { + allowInsecureHTTP = cfg.Security.URLAllowlist.AllowInsecureHTTP + } + normalized, err := urlvalidator.ValidateURLFormat(trimmed, allowInsecureHTTP) + if err != nil { + return "", fmt.Errorf("probe target rejected by URL security policy: %w", err) + } + return normalized, nil +} diff --git a/backend/internal/service/cn_provider_probe_url_test.go b/backend/internal/service/cn_provider_probe_url_test.go new file mode 100644 index 0000000000..f285088400 --- /dev/null +++ b/backend/internal/service/cn_provider_probe_url_test.go @@ -0,0 +1,129 @@ +package service + +// CN 供应商探测端点 URL 安全策略回归测试(review B4): +// 配额/余额探测不得绕过 security.url_allowlist——base_url 衍生的探测端点 +// 必须先过运营者策略,被拒绝时不得发起任何上游请求(API key 不出站)。 + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +func cnProbeAllowlistConfig(hosts ...string) *config.Config { + return &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{ + Enabled: true, + UpstreamHosts: hosts, + }, + }, + } +} + +func TestCNValidateProbeURL_AllowlistPolicy(t *testing.T) { + cfg := cnProbeAllowlistConfig("api.moonshot.cn", "api.deepseek.com") + + // 白名单内主机放行(保留完整路径)。 + ok, err := cnValidateProbeURL(cfg, "https://api.moonshot.cn/v1/users/me/balance") + require.NoError(t, err) + require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", ok) + + // 白名单外主机拒绝。 + _, err = cnValidateProbeURL(cfg, "https://relay.attacker.example/v1/usages") + require.Error(t, err) + require.Contains(t, err.Error(), "rejected by URL security policy") + + // 私网主机拒绝(内网探测面)。 + _, err = cnValidateProbeURL(cfg, "http://169.254.169.254/latest/meta-data") + require.Error(t, err) + + // 白名单关闭:仅格式校验,任意 https 主机放行。 + formatOnly, err := cnValidateProbeURL(&config.Config{ + Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}, + }, "https://relay.attacker.example/v1/usages") + require.NoError(t, err) + require.Equal(t, "https://relay.attacker.example/v1/usages", formatOnly) +} + +// recordingHTTPUpstream 断言探测被策略拒绝时没有任何上游请求发出。 +type recordingHTTPUpstream struct{ calls int } + +func (u *recordingHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { + u.calls++ + return nil, context.DeadlineExceeded +} + +func (u *recordingHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) { + u.calls++ + return nil, context.DeadlineExceeded +} + +type fakeCNProbeAccountRepo struct { + AccountRepository + account *Account +} + +func (r *fakeCNProbeAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + return r.account, nil +} + +// kimi coding 账号的 base_url 指向中转(含 api.kimi.com/coding 路径段即可被识别 +// 为 kimi coding plan)→ 衍生额度端点落在中转主机上,白名单未列名必须拒绝。 +func TestCNProviderQuotaService_RejectsURLBlockedByPolicy(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{ + "account_mode": "coding", + "api_key": "sk-test", + "base_url": "https://relay.attacker.example/api.kimi.com/coding", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, cnProbeAllowlistConfig("api.kimi.com")) + + _, err := svc.QueryUsage(context.Background(), 1) + require.Error(t, err) + require.Contains(t, err.Error(), "CN_QUOTA_URL_REJECTED") + require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target") +} + +// deepseek payg 账号自定义 base_url → 余额端点落在中转主机上,必须先过策略。 +func TestCNProviderBalanceService_RejectsURLBlockedByPolicy(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 2, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{ + "account_mode": "payg", + "api_key": "sk-test", + "base_url": "https://relay.attacker.example", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com")) + + _, err := svc.QueryBalance(context.Background(), 2) + require.Error(t, err) + require.Contains(t, err.Error(), "CN_BALANCE_URL_REJECTED") + require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target") +} + +// 白名单包含官方主机的正常路径:URL 校验通过后才发出上游请求(此处允许到达 +// httpUpstream 层即视为通过校验,不发真实网络)。 +func TestCNProviderBalanceService_OfficialHostPassesValidation(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 3, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{ + "account_mode": "payg", + "api_key": "sk-test", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com")) + + _, _ = svc.QueryBalance(context.Background(), 3) + require.Equal(t, 1, upstream.calls, "official host must pass URL policy and reach the upstream layer") +} diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go new file mode 100644 index 0000000000..271b18afe1 --- /dev/null +++ b/backend/internal/service/cn_provider_quota_service.go @@ -0,0 +1,582 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/tidwall/gjson" + "golang.org/x/sync/singleflight" +) + +// 国产供应商 Coding Plan 滚动窗口额度探测服务(Kimi For Coding / 智谱 GLM Coding Plan)。 +// +// 与 grok_quota_service 不同:CN 供应商走数据面 API Key(无 OAuth token provider), +// 额度端点为只读 GET,解析 5h + weekly 两档滚动窗口并落 account.Extra 快照, +// 供账号调度阈值评估(account_scheduling_threshold_eval.go)做主动停调。 +// +// 解析逻辑对齐 cc-switch(farion1231/cc-switch)services/coding_plan.rs 的 +// query_kimi / query_zhipu,包括智谱 unit 字段优先分类与 reset 兜底启发式。 +const ( + cnQuotaUpstreamTimeout = 15 * time.Second + cnQuotaMaxBodyBytes = 256 * 1024 + + // Extra 快照键后缀(加 provider 前缀,如 kimi_5h_used_percent)。 + cnExtraSuffix5hUsed = "5h_used_percent" + cnExtraSuffix5hReset = "5h_reset_at" + cnExtraSuffixWeeklyUsed = "weekly_used_percent" + cnExtraSuffixWeeklyReset = "weekly_reset_at" + cnExtraSuffixUsageUpdated = "usage_updated_at" +) + +// cnExtraKey 拼接 provider 维度的 extra 键。 +func cnExtraKey(provider, suffix string) string { return provider + "_" + suffix } + +// CNQuotaTier 表示一个滚动用量窗口档位(5h / weekly)。 +type CNQuotaTier struct { + Window string `json:"window"` // "5h" | "weekly" + UsedPercent float64 `json:"used_percent"` // 已用百分比(0-100+,不做裁剪) + ResetAt string `json:"reset_at,omitempty"` // RFC3339,空表示无重置时间 +} + +// CNProviderQuotaProbeResult 是 Coding Plan 额度探测的返回结构(管理端 + UI 消费)。 +type CNProviderQuotaProbeResult struct { + Provider string `json:"provider"` + Source string `json:"source"` + Success bool `json:"success"` + CredentialValid bool `json:"credential_valid"` // false = 401/403 鉴权失败 + Tiers []CNQuotaTier `json:"tiers,omitempty"` + PlanLevel string `json:"plan_level,omitempty"` // 智谱套餐等级 + StatusCode int `json:"status_code,omitempty"` + FetchedAt int64 `json:"fetched_at"` + Persisted bool `json:"persisted"` + Error string `json:"error,omitempty"` +} + +// CNProviderQuotaService 探测 Kimi / Zhipu Coding Plan 的滚动窗口用量。 +type CNProviderQuotaService struct { + accountRepo AccountRepository + proxyRepo ProxyRepository + httpUpstream HTTPUpstream + cfg *config.Config + flight singleflight.Group +} + +// NewCNProviderQuotaService 构造 Coding Plan 额度探测服务。 +func NewCNProviderQuotaService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderQuotaService { + return &CNProviderQuotaService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + httpUpstream: httpUpstream, + cfg: cfg, + } +} + +// QueryUsage 探测指定账号的 Coding Plan 滚动窗口用量并落 Extra 快照。 +// 同一账号的并发探测会被 singleflight 合并。 +func (s *CNProviderQuotaService) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + account, err := s.loadCodingPlanAccount(ctx, accountID) + if err != nil { + return nil, err + } + return s.QueryUsageForAccount(ctx, account) +} + +// QueryUsageForAccount 探测已加载账号(配额监控 fetcher 复用,避免二次 GetByID)。 +// singleflight key 与 QueryUsage 相同,按账号 ID 与 admin 侧并发探测合并。 +func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { + if s == nil || s.accountRepo == nil || s.httpUpstream == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_QUOTA_NOT_CONFIGURED", "cn provider quota service is not configured") + } + if err := validateCodingPlanAccount(account); err != nil { + return nil, err + } + key := "cn_quota:" + strconv.FormatInt(account.ID, 10) + resultCh := s.flight.DoChan(key, func() (any, error) { + probeCtx, cancel := context.WithTimeout(context.Background(), cnQuotaUpstreamTimeout+5*time.Second) + defer cancel() + return s.queryUsageForAccount(probeCtx, account) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case flightResult := <-resultCh: + if flightResult.Err != nil { + return nil, flightResult.Err + } + result, ok := flightResult.Val.(*CNProviderQuotaProbeResult) + if !ok || result == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_QUOTA_PROBE_RESULT_INVALID", "invalid cn provider quota probe result") + } + cloned := *result + return &cloned, nil + } +} + +func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { + provider := account.GetCodingPlanProvider() + if provider != PlatformKimi && provider != PlatformZhipu { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu coding plan account") + } + + apiKey := strings.TrimSpace(account.GetCNAPIKey()) + if apiKey == "" { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NO_APIKEY", "account api_key is empty") + } + + baseURL := account.GetOpenAIBaseURL() + var ( + targetURL string + authHeader string + ) + switch provider { + case PlatformKimi: + targetURL = kimiQuotaURL(baseURL) + authHeader = "Bearer " + apiKey + case PlatformZhipu: + targetURL = zhipuQuotaURL(baseURL) + authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀 + } + + // 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验): + // 端点多由账号 base_url 衍生,不得把 API key 发往策略外主机。 + validatedURL, err := cnValidateProbeURL(s.cfg, targetURL) + if err != nil { + return nil, infraerrors.New(http.StatusForbidden, "CN_QUOTA_URL_REJECTED", err.Error()) + } + targetURL = validatedURL + + proxyURL := s.resolveProxyURL(ctx, account) + callCtx, cancel := context.WithTimeout(ctx, cnQuotaUpstreamTimeout) + defer cancel() + req, err := http.NewRequestWithContext(callCtx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "CN_QUOTA_REQUEST_BUILD_FAILED", "build request: %v", err) + } + req.Header.Set("Authorization", authHeader) + req.Header.Set("Accept", "application/json") + if provider == PlatformZhipu { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "en-US,en") + } + // 探测与真实转发保持同一套账号级请求头覆写,避免探测通过但转发失败。 + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1)) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "CN_QUOTA_REQUEST_FAILED", "upstream request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, cnQuotaMaxBodyBytes)) + + now := time.Now().UTC() + result := &CNProviderQuotaProbeResult{ + Provider: provider, + Source: "coding_plan", + FetchedAt: now.Unix(), + StatusCode: resp.StatusCode, + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + // 鉴权失败:不落快照(不覆盖之前的有效值),仅返回失败结果供前端提示。 + result.Error = fmt.Sprintf("Authentication failed (HTTP %d)", resp.StatusCode) + return result, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, truncate(strings.TrimSpace(string(bodyBytes)), 240)) + return result, nil + } + + // 智谱业务级错误(HTTP 2xx 但 success=false)。 + if provider == PlatformZhipu { + if success := gjson.GetBytes(bodyBytes, "success"); success.Exists() && !success.Bool() { + msg := strings.TrimSpace(gjson.GetBytes(bodyBytes, "msg").String()) + if msg == "" { + msg = "unknown zhipu quota error" + } + result.Error = "API error: " + msg + return result, nil + } + } + + var tiers []CNQuotaTier + switch provider { + case PlatformKimi: + tiers = parseKimiUsageTiers(bodyBytes) + case PlatformZhipu: + tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data")) + result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String()) + } + result.Tiers = tiers + result.Success = true + result.CredentialValid = true + + updates := cnQuotaExtraUpdates(provider, tiers, now) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { + slog.Warn("cn_quota_persist_failed", "account_id", account.ID, "provider", provider, "error", err) + } else { + result.Persisted = true + } + return result, nil +} + +func (s *CNProviderQuotaService) loadCodingPlanAccount(ctx context.Context, accountID int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, infraerrors.Newf(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err) + } + if err := validateCodingPlanAccount(account); err != nil { + return nil, err + } + return account, nil +} + +// validateCodingPlanAccount 加载后的非 DB 校验(ForAccount 入口同样复用, +// 保证直传 account 也不绕过平台/模式检查)。 +func validateCodingPlanAccount(account *Account) error { + if account == nil { + return infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found") + } + if !account.IsCNProvider() { + return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account") + } + if !account.IsCodingPlan() { + return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a coding plan account") + } + return nil +} + +func (s *CNProviderQuotaService) resolveProxyURL(ctx context.Context, account *Account) string { + if account == nil || account.ProxyID == nil { + return "" + } + if account.Proxy != nil { + return account.Proxy.URL() + } + if s != nil && s.proxyRepo != nil { + if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil { + account.Proxy = proxy + return proxy.URL() + } + } + return "" +} + +// zhipuQuotaURL 根据 base_url 解析智谱额度端点(与数据面推理域名同主机)。 +func zhipuQuotaURL(baseURL string) string { + return zhipuQuotaHost(baseURL) + "/api/monitor/usage/quota/limit" +} + +// kimiQuotaURL 根据 base_url 解析 Kimi For Coding 额度端点。 +// cc-switch query_kimi 固定探测 https://api.kimi.com/coding/v1/usages +// (实测 /coding/usages 无 /v1 → 404)。coding/v1(CC 协议默认)与 +// coding(Anthropic 协议默认)两种 base 统一剥掉尾部后拼回 /v1/usages, +// 协议切换不影响额度探测端点。 +func kimiQuotaURL(baseURL string) string { + base := strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/v1") + return base + "/v1/usages" +} + +func zhipuQuotaHost(baseURL string) string { + switch u := strings.ToLower(baseURL); { + case strings.Contains(u, "bigmodel.cn"): + return "https://open.bigmodel.cn" + case strings.Contains(u, "z.ai"): + return "https://api.z.ai" + default: + // 国产优先:未知域名回落国内站(与前端 zhipu 预设一致)。 + return "https://open.bigmodel.cn" + } +} + +// parseKimiUsageTiers 解析 Kimi For Coding 的 /usages 响应。 +// +// - limits[].detail.{limit,remaining,resetTime} → 5h 窗口(取首个 detail) +// - usage.{limit,remaining,resetTime} → 周窗口 +// +// utilization = (limit-remaining)/limit*100。 +func parseKimiUsageTiers(body []byte) []CNQuotaTier { + var tiers []CNQuotaTier + + if limits := gjson.GetBytes(body, "limits"); limits.IsArray() { + limits.ForEach(func(_, item gjson.Result) bool { + detail := item.Get("detail") + if !detail.Exists() { + return true + } + limit, _ := cnParseF64(detail.Get("limit").Value()) + remaining, _ := cnParseF64(detail.Get("remaining").Value()) + used := limit - remaining + if used < 0 { + used = 0 + } + var util float64 + if limit > 0 { + util = used / limit * 100 + } + tiers = append(tiers, CNQuotaTier{ + Window: "5h", + UsedPercent: util, + ResetAt: cnNormalizeResetTime(detail.Get("resetTime").Value()), + }) + return false // 取首个 detail 作为 5h 窗口 + }) + } + + if usage := gjson.GetBytes(body, "usage"); usage.Exists() { + limit, _ := cnParseF64(usage.Get("limit").Value()) + remaining, _ := cnParseF64(usage.Get("remaining").Value()) + used := limit - remaining + if used < 0 { + used = 0 + } + var util float64 + if limit > 0 { + util = used / limit * 100 + } + tiers = append(tiers, CNQuotaTier{ + Window: "weekly", + UsedPercent: util, + ResetAt: cnNormalizeResetTime(usage.Get("resetTime").Value()), + }) + } + + return tiers +} + +// cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。 +type cnZhipuWindow int + +const ( + cnZhipuWindowUnknown cnZhipuWindow = iota + cnZhipuWindow5h + cnZhipuWindowWeekly +) + +// classifyZhipuWindowUnit 按 unit 字段判定窗口类型(3=5h,6=weekly)。 +// unit 缺失或未识别时返回 Unknown,由调用方走 reset 时间启发式兜底。 +func classifyZhipuWindowUnit(unit int64) cnZhipuWindow { + switch unit { + case 3: + return cnZhipuWindow5h + case 6: + return cnZhipuWindowWeekly + default: + return cnZhipuWindowUnknown + } +} + +// parseZhipuTokenTiers 解析智谱额度响应 data.limits 为 5h + weekly 两档。 +// +// 分类优先级(对齐 cc-switch parse_zhipu_token_tiers,issue #3036): +// 1. 显式 unit 字段(3=5h / 6=weekly)——不能用 reset 排序代替,周期末尾 +// 周窗口会比 5h 更早重置,时间排序必然标反。 +// 2. unit 缺失/未识别:无 nextResetTime 的条目优先归 5h(0% 状态下 5h 桶可能 +// 没有 reset),其余按 reset 升序依次填入仍空缺的槽位。 +// +// CREDIT_LIMIT(信用额度)与 TOKENS_LIMIT(token 窗口)度量不同:两者同时返回时 +// 只让 TOKENS_LIMIT 参与 5h/weekly 槽位竞争,避免信用额度百分比污染阈值停调 +// 快照;仅当无任何 TOKENS_LIMIT 条目时才降级用 CREDIT_LIMIT 展示。 +// 老套餐只回 1 条 TOKENS_LIMIT,自然降级为仅 5h;新套餐回 2 条。 +func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier { + type entry struct { + resetMs int64 + hasReset bool + percentage float64 + resetISO string + } + var ( + fiveHour entry + fiveHourSet bool + weekly entry + weeklySet bool + unclassified []entry + ) + + classify := func(item gjson.Result, e entry) { + switch classifyZhipuWindowUnit(item.Get("unit").Int()) { + case cnZhipuWindow5h: + if !fiveHourSet { + fiveHour, fiveHourSet = e, true + } else { + unclassified = append(unclassified, e) + } + case cnZhipuWindowWeekly: + if !weeklySet { + weekly, weeklySet = e, true + } else { + unclassified = append(unclassified, e) + } + default: + unclassified = append(unclassified, e) + } + } + var creditFallback []entry + hasTokensLimit := false + + data.Get("limits").ForEach(func(_, item gjson.Result) bool { + limitType := strings.ToUpper(strings.TrimSpace(item.Get("type").String())) + if limitType != "TOKENS_LIMIT" && limitType != "CREDIT_LIMIT" { + return true + } + percentage := 0.0 + if p, ok := cnParseF64(item.Get("percentage").Value()); ok { + percentage = p + } + var ( + resetMs int64 + hasReset bool + resetISO string + ) + if nr := item.Get("nextResetTime"); nr.Exists() { + switch nr.Type { + case gjson.Number: + resetMs = nr.Int() + hasReset = resetMs > 0 + resetISO = cnMillisToRFC3339(resetMs) + case gjson.String: + resetISO = cnNormalizeResetTime(nr.String()) + hasReset = resetISO != "" + } + } + e := entry{resetMs: resetMs, hasReset: hasReset, percentage: percentage, resetISO: resetISO} + if limitType == "TOKENS_LIMIT" { + hasTokensLimit = true + classify(item, e) + } else { + creditFallback = append(creditFallback, e) + } + return true + }) + + // 无任何 TOKENS_LIMIT 条目(部分套餐只报信用额度):降级用 CREDIT_LIMIT 展示。 + if !hasTokensLimit { + unclassified = append(unclassified, creditFallback...) + } + + // 无 reset 的条目排前,再按 reset 升序,依次填入仍空缺的槽位。 + sort.SliceStable(unclassified, func(i, j int) bool { + if unclassified[i].hasReset != unclassified[j].hasReset { + return !unclassified[i].hasReset + } + return unclassified[i].resetMs < unclassified[j].resetMs + }) + for _, e := range unclassified { + switch { + case !fiveHourSet: + fiveHour, fiveHourSet = e, true + case !weeklySet: + weekly, weeklySet = e, true + } + } + + var tiers []CNQuotaTier + if fiveHourSet { + tiers = append(tiers, CNQuotaTier{Window: "5h", UsedPercent: fiveHour.percentage, ResetAt: fiveHour.resetISO}) + } + if weeklySet { + tiers = append(tiers, CNQuotaTier{Window: "weekly", UsedPercent: weekly.percentage, ResetAt: weekly.resetISO}) + } + return tiers +} + +// cnQuotaExtraUpdates 根据 tier 列表构造 provider 维度的 Extra 快照更新。 +func cnQuotaExtraUpdates(provider string, tiers []CNQuotaTier, now time.Time) map[string]any { + updates := map[string]any{ + cnExtraKey(provider, cnExtraSuffixUsageUpdated): now.Format(time.RFC3339), + } + for _, t := range tiers { + switch t.Window { + case "5h": + updates[cnExtraKey(provider, cnExtraSuffix5hUsed)] = t.UsedPercent + if t.ResetAt != "" { + updates[cnExtraKey(provider, cnExtraSuffix5hReset)] = t.ResetAt + } + case "weekly": + updates[cnExtraKey(provider, cnExtraSuffixWeeklyUsed)] = t.UsedPercent + if t.ResetAt != "" { + updates[cnExtraKey(provider, cnExtraSuffixWeeklyReset)] = t.ResetAt + } + } + } + return updates +} + +// cnParseF64 把 JSON 数值或字符串解析为 float64(兼容 "100" 与 100)。 +func cnParseF64(raw any) (float64, bool) { + switch v := raw.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + f, err := v.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + return f, err == nil + default: + return 0, false + } +} + +// cnNormalizeResetTime 把上游重置时间(ISO8601 字符串 / 秒级 / 毫秒级数字)归一化为 +// RFC3339 字符串;无法识别或非正时间戳返回空串。 +func cnNormalizeResetTime(raw any) string { + switch v := raw.(type) { + case string: + s := strings.TrimSpace(v) + if s == "" { + return "" + } + if ts, err := parseSchedulingTime(s); err == nil { + return ts.UTC().Format(time.RFC3339) + } + return "" + case float64: + return cnMillisToRFC3339(int64(v)) + case int: + return cnMillisToRFC3339(int64(v)) + case int64: + return cnMillisToRFC3339(v) + case json.Number: + if n, err := v.Int64(); err == nil { + return cnMillisToRFC3339(n) + } + return "" + default: + return "" + } +} + +// cnMillisToRFC3339 把秒级(<1e12)或毫秒级时间戳转为 RFC3339 字符串;非正返回空串。 +func cnMillisToRFC3339(n int64) string { + if n <= 0 { + return "" + } + var ms int64 + if n < 1_000_000_000_000 { + ms = n * 1000 + } else { + ms = n + } + return time.UnixMilli(ms).UTC().Format(time.RFC3339) +} diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go new file mode 100644 index 0000000000..7ec7a4fddd --- /dev/null +++ b/backend/internal/service/cn_providers_test.go @@ -0,0 +1,689 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +// TestCNExtraKey 验证 provider 维度的 Extra 快照键由前缀 + 后缀拼接。 +func TestCNExtraKey(t *testing.T) { + t.Parallel() + require.Equal(t, "kimi_5h_used_percent", cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed)) + require.Equal(t, "zhipu_weekly_reset_at", cnExtraKey(PlatformZhipu, cnExtraSuffixWeeklyReset)) + require.Equal(t, "deepseek_balance", cnExtraKey(PlatformDeepseek, cnBalanceExtraSuffixBalance)) +} + +// TestCNParseF64 兼容 JSON 数值与字符串(cc-switch 与上游字段类型不一致)。 +func TestCNParseF64(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw any + want float64 + ok bool + }{ + {"float64", float64(12.5), 12.5, true}, + {"int", 100, 100, true}, + {"numeric string", "33.3", 33.3, true}, + {"trim string", " 7 ", 7, true}, + {"non-numeric string", "abc", 0, false}, + {"nil", nil, 0, false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := cnParseF64(tc.raw) + require.Equal(t, tc.ok, ok) + if ok { + require.InDelta(t, tc.want, got, 1e-9) + } + }) + } +} + +// TestCNMillisToRFC3339 秒级(<1e12)按秒、毫秒级按毫秒处理;非正返回空串。 +func TestCNMillisToRFC3339(t *testing.T) { + t.Parallel() + // 1700000000 秒 = 1700000000000 毫秒 + want := time.UnixMilli(1700000000000).UTC().Format(time.RFC3339) + require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级 + require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级 + require.Equal(t, "", cnMillisToRFC3339(0)) // 非正 + require.Equal(t, "", cnMillisToRFC3339(-1)) +} + +// TestCnnormalizeResetTime 覆盖 ISO8601 字符串 / 数字(秒、毫秒)/ 非法输入。 +func TestCnnormalizeResetTime(t *testing.T) { + t.Parallel() + // ISO8601 字符串归一化为 RFC3339(UTC)。 + require.Equal(t, "2026-08-14T10:00:00Z", cnNormalizeResetTime("2026-08-14T10:00:00Z")) + // 毫秒级 float64。 + require.Equal(t, + time.UnixMilli(1700000000000).UTC().Format(time.RFC3339), + cnNormalizeResetTime(float64(1700000000000))) + // 非法字符串。 + require.Equal(t, "", cnNormalizeResetTime("not-a-time")) + require.Equal(t, "", cnNormalizeResetTime("")) +} + +// TestParseKimiUsageTiers 验证 Kimi For Coding /usages 解析: +// - 首个 limits[].detail → 5h 桶,utilization=(limit-remaining)/limit*100 +// - usage → weekly 桶 +// - 仅取首个 detail(多个 detail 时不应重复产出 5h) +func TestParseKimiUsageTiers(t *testing.T) { + t.Parallel() + body := []byte(`{ + "limits": [ + {"name": "5h", "detail": {"limit": 1000, "remaining": 600, "resetTime": "2026-08-14T15:00:00Z"}}, + {"name": "ignored-second-detail", "detail": {"limit": 999, "remaining": 0, "resetTime": "2026-08-14T20:00:00Z"}} + ], + "usage": {"limit": 10000, "remaining": 4000, "resetTime": "2026-08-18T00:00:00Z"} + }`) + tiers := parseKimiUsageTiers(body) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 40.0, tiers[0].UsedPercent, 1e-9) // (1000-600)/1000*100 + require.Equal(t, "2026-08-14T15:00:00Z", tiers[0].ResetAt) + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 60.0, tiers[1].UsedPercent, 1e-9) // (10000-4000)/10000*100 + require.Equal(t, "2026-08-18T00:00:00Z", tiers[1].ResetAt) +} + +// TestParseKimiUsageTiers_LimitZero 不应除零:limit=0 → utilization=0。 +func TestParseKimiUsageTiers_LimitZero(t *testing.T) { + t.Parallel() + body := []byte(`{"limits":[{"detail":{"limit":0,"remaining":0,"resetTime":"2026-08-14T15:00:00Z"}}]}`) + tiers := parseKimiUsageTiers(body) + require.Len(t, tiers, 1) + require.InDelta(t, 0.0, tiers[0].UsedPercent, 1e-9) +} + +// TestParseZhipuTokenTiers_UnitClassification 显式 unit(3=5h / 6=weekly)优先分类, +// 不能被 reset 时间排序覆盖(周期末尾周窗口会更早重置)。 +func TestParseZhipuTokenTiers_UnitClassification(t *testing.T) { + t.Parallel() + // weekly 的 nextResetTime 早于 5h(模拟周期末尾),但 unit 必须胜出。 + data := gjson.Parse(`{ + "limits": [ + {"type":"TOKENS_LIMIT","unit":6,"percentage":70,"nextResetTime":1700000000000}, + {"type":"TOKENS_LIMIT","unit":3,"percentage":20,"nextResetTime":1700000099999} + ] + }`) + tiers := parseZhipuTokenTiers(data) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 20.0, tiers[0].UsedPercent, 1e-9) + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 70.0, tiers[1].UsedPercent, 1e-9) +} + +// TestParseZhipuTokenTiers_SingleTierOldPlan 老套餐仅回 1 条 → 降级为仅 5h。 +func TestParseZhipuTokenTiers_SingleTierOldPlan(t *testing.T) { + t.Parallel() + data := gjson.Parse(`{"limits":[{"type":"TOKENS_LIMIT","unit":3,"percentage":15,"nextResetTime":1700000000000}]}`) + tiers := parseZhipuTokenTiers(data) + require.Len(t, tiers, 1) + require.Equal(t, "5h", tiers[0].Window) +} + +// TestParseZhipuTokenTiers_FallbackHeuristic unit 缺失时:无 reset 的条目优先归 5h, +// 其余按 reset 升序填入剩余槽位。 +func TestParseZhipuTokenTiers_FallbackHeuristic(t *testing.T) { + t.Parallel() + // 无 unit:A 无 reset、B 有 reset。A 先填 5h,B 填 weekly。 + data := gjson.Parse(`{ + "limits": [ + {"type":"TOKENS_LIMIT","percentage":50,"nextResetTime":1700000000000}, + {"type":"TOKENS_LIMIT","percentage":10} + ] + }`) + tiers := parseZhipuTokenTiers(data) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 10.0, tiers[0].UsedPercent, 1e-9) // 无 reset 优先 5h + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 50.0, tiers[1].UsedPercent, 1e-9) +} + +// TestParseZhipuTokenTiers_IgnoresNonTokenEntries 非 TOKENS_LIMIT/CREDIT_LIMIT 条目跳过。 +func TestParseZhipuTokenTiers_IgnoresNonTokenEntries(t *testing.T) { + t.Parallel() + data := gjson.Parse(`{"limits":[{"type":"OTHER_LIMIT","unit":3,"percentage":99}]}`) + require.Empty(t, parseZhipuTokenTiers(data)) +} + +// TestCNQuotaExtraUpdates 验证 tier 列表落 Extra 快照键的 provider 前缀与窗口映射。 +func TestCNQuotaExtraUpdates(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) + tiers := []CNQuotaTier{ + {Window: "5h", UsedPercent: 40, ResetAt: "2026-08-14T15:00:00Z"}, + {Window: "weekly", UsedPercent: 60, ResetAt: "2026-08-18T00:00:00Z"}, + } + updates := cnQuotaExtraUpdates(PlatformKimi, tiers, now) + require.Equal(t, 40.0, updates["kimi_5h_used_percent"]) + require.Equal(t, "2026-08-14T15:00:00Z", updates["kimi_5h_reset_at"]) + require.Equal(t, 60.0, updates["kimi_weekly_used_percent"]) + require.Equal(t, "2026-08-18T00:00:00Z", updates["kimi_weekly_reset_at"]) + require.Equal(t, now.Format(time.RFC3339), updates["kimi_usage_updated_at"]) +} + +// TestCNProviderResponseIndicatesInsufficientBalance 覆盖中英文余额不足文案与否定用例。 +func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) { + t.Parallel() + positive := []string{ + `{"error":{"message":"余额不足"}}`, + `{"error":{"message":"Insufficient balance"}}`, + `{"code":"insufficient_credit"}`, + `"balance is not enough"`, + `"no enough balance"`, + } + for _, body := range positive { + require.True(t, cnProviderResponseIndicatesInsufficientBalance([]byte(body)), body) + } + negative := []string{ + `{"error":{"message":"rate limit exceeded"}}`, + `{"error":{"message":"quota exhausted"}}`, + ``, + } + for _, body := range negative { + require.False(t, cnProviderResponseIndicatesInsufficientBalance([]byte(body)), body) + } +} + +// TestCNBalanceLowReason 验证稳定前缀(供周期检测任务识别并清除)。 +func TestCNBalanceLowReason(t *testing.T) { + t.Parallel() + require.Equal(t, "cn_balance_low: upstream said x", + cnBalanceLowReason("upstream said x")) + require.Equal(t, "cn_balance_low: 余额不足,账号临时停调", + cnBalanceLowReason(" ")) + require.True(t, len(cnBalanceLowReason("")) > len(cnBalanceLowReasonPrefix)) +} + +// TestZhipuQuotaHost 按域名路由智谱额度端点主机(bigmodel.cn / z.ai / 默认国内站)。 +func TestZhipuQuotaHost(t *testing.T) { + t.Parallel() + require.Equal(t, "https://open.bigmodel.cn", zhipuQuotaHost("https://open.bigmodel.cn/api/paas/v4")) + require.Equal(t, "https://api.z.ai", zhipuQuotaHost("https://api.z.ai/api/paas/v4")) + require.Equal(t, "https://open.bigmodel.cn", zhipuQuotaHost("https://custom.example.com")) // 默认国内站 + require.Equal(t, "https://open.bigmodel.cn/api/monitor/usage/quota/limit", zhipuQuotaURL("https://open.bigmodel.cn/api/paas/v4")) +} + +// TestKimiQuotaURL 两种协议默认 base(coding/v1 与 coding)都归一到 /coding/v1/usages +// (cc-switch 固定端点;无 /v1 的 /coding/usages 实测 404)。 +func TestKimiQuotaURL(t *testing.T) { + t.Parallel() + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1")) + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding")) + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/")) + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1/")) +} + +// TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。 +func TestCNBalanceURL(t *testing.T) { + t.Parallel() + kimi := &Account{Platform: PlatformKimi} + require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", cnBalanceURL(kimi)) + + deepseek := &Account{ + Platform: PlatformDeepseek, + Credentials: map[string]any{"base_url": "https://api.deepseek.com"}, + } + require.Equal(t, "https://api.deepseek.com/user/balance", cnBalanceURL(deepseek)) +} + +// TestCNProviderThresholdCandidates 从 Extra 快照读取 5h / weekly 候选。 +func TestCNProviderThresholdCandidates(t *testing.T) { + t.Parallel() + account := &Account{ + Platform: PlatformKimi, + Extra: map[string]any{ + "kimi_5h_used_percent": 90.0, + "kimi_5h_reset_at": "2026-08-14T15:00:00Z", + "kimi_weekly_used_percent": 50.0, + "kimi_weekly_reset_at": "2026-08-18T00:00:00Z", + }, + } + cands := cnProviderThresholdCandidates(account, PlatformKimi) + // 仅返回非 nil 候选(两窗口均存在 → 2 条)。 + var present []*accountSchedulingThresholdCandidate + for _, c := range cands { + if c != nil { + present = append(present, c) + } + } + require.Len(t, present, 2) + + // 缺少 used 键的窗口不产生候选。 + partial := &Account{ + Platform: PlatformKimi, + Extra: map[string]any{"kimi_5h_reset_at": "2026-08-14T15:00:00Z"}, // 无 used_percent + } + require.Empty(t, filterNil(cnProviderThresholdCandidates(partial, PlatformKimi))) + + // 空 Extra / nil account 安全返回。 + require.Empty(t, filterNil(cnProviderThresholdCandidates(&Account{Platform: PlatformKimi}, PlatformKimi))) +} + +func filterNil(cands []*accountSchedulingThresholdCandidate) []*accountSchedulingThresholdCandidate { + var out []*accountSchedulingThresholdCandidate + for _, c := range cands { + if c != nil { + out = append(out, c) + } + } + return out +} + +// TestEvaluateAccountSchedulingThreshold_KimiCodingPlan 集成验证:kimi coding 账号 +// 5h 用量超阈值且窗口未重置 → 主动停调至 5h 重置点。 +func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + reset := now.Add(3 * time.Hour) + account := &Account{ + Platform: PlatformKimi, + Extra: map[string]any{ + "kimi_5h_used_percent": 90.0, + "kimi_5h_reset_at": reset.Format(time.RFC3339), + "kimi_weekly_used_percent": 30.0, + "kimi_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), + }, + } + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformKimi: 80}, now) + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformKimi, decision.Platform) + require.Equal(t, "5h", decision.Window) + require.InDelta(t, 90.0, decision.UsedPercent, 1e-9) + require.NotNil(t, decision.Until) + require.True(t, reset.Equal(*decision.Until)) +} + +// TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped 窗口已重置(reset<=now) +// 或用量低于阈值 → 不停调(candidateMatchesThreshold 要求 until.After(now))。 +func TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + // 重置时间已过。 + expired := &Account{ + Platform: PlatformZhipu, + Extra: map[string]any{ + "zhipu_5h_used_percent": 99.0, + "zhipu_5h_reset_at": now.Add(-1 * time.Hour).Format(time.RFC3339), + }, + } + require.False(t, EvaluateAccountSchedulingThreshold(expired, map[string]int{PlatformZhipu: 80}, now).ShouldPause) + + // 用量低于阈值。 + low := &Account{ + Platform: PlatformZhipu, + Extra: map[string]any{ + "zhipu_5h_used_percent": 20.0, + "zhipu_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339), + }, + } + require.False(t, EvaluateAccountSchedulingThreshold(low, map[string]int{PlatformZhipu: 80}, now).ShouldPause) +} + +// TestCNProviderQuotaSnapshotReset Coding Plan 429 冷却:取快照中最早的「仍在未来」窗口重置点。 +func TestCNProviderQuotaSnapshotReset(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + future5h := now.Add(2 * time.Hour) + futureWeekly := now.Add(3 * 24 * time.Hour) + pastWeekly := now.Add(-24 * time.Hour) + + // 5h 在未来、weekly 已过期 → 返回 5h。 + account := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + Extra: map[string]any{ + "kimi_5h_reset_at": future5h.Format(time.RFC3339), + "kimi_weekly_reset_at": pastWeekly.Format(time.RFC3339), + }, + } + got := cnProviderQuotaSnapshotReset(account, now) + require.NotNil(t, got) + require.True(t, future5h.Equal(*got)) + + // 两窗口均在未来 → 取较早者(429 多由 5h 窗口触发,避免冷却到 weekly 重置)。 + both := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + Extra: map[string]any{ + "kimi_5h_reset_at": future5h.Format(time.RFC3339), + "kimi_weekly_reset_at": futureWeekly.Format(time.RFC3339), + }, + } + gotBoth := cnProviderQuotaSnapshotReset(both, now) + require.NotNil(t, gotBoth) + require.True(t, future5h.Equal(*gotBoth)) + + // 两窗口均过期 → nil。 + expired := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + Extra: map[string]any{ + "kimi_5h_reset_at": pastWeekly.Format(time.RFC3339), + "kimi_weekly_reset_at": pastWeekly.Format(time.RFC3339), + }, + } + require.Nil(t, cnProviderQuotaSnapshotReset(expired, now)) + + // payg 账号(非 coding)→ nil(余额型走余额检测)。 + payg := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModePayG}, + Extra: map[string]any{"kimi_5h_reset_at": future5h.Format(time.RFC3339)}, + } + require.Nil(t, cnProviderQuotaSnapshotReset(payg, now)) +} + +// TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch 回归保护: +// grok 与国产供应商原样保留,其余归一为 openai —— 保证 kimi/zhipu/deepseek 分组请求 +// 精确匹配同名账号(与 openai/grok 当前行为一致),不会错误并入 openai 池。 +func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) { + t.Parallel() + require.Equal(t, PlatformGrok, NormalizeOpenAICompatiblePlatform(PlatformGrok)) + require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi)) + require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu)) + require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek)) + // 其他平台(含空、anthropic、未知)一律归一为 openai。 + require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("")) + require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic)) + require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("something-else")) +} + +// TestGetOpenAIProtocolAPIKey_CNProviders 验证 OpenAI 协议族密钥读取覆盖国产供应商, +// 同时保持 IsOpenAIApiKey 的 openai-only 语义(调度倍率/WS 门控不受影响)。 +func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) { + t.Parallel() + + kimi := &Account{ + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-kimi"}, + } + require.Equal(t, "sk-kimi", kimi.GetOpenAIProtocolAPIKey()) + require.False(t, kimi.IsOpenAIApiKey(), "IsOpenAIApiKey stays openai-only for scheduling gates") + + // 非 APIKey 类型的 CN 账号不返回密钥 + notAPIKey := &Account{ + Platform: PlatformDeepseek, + Type: AccountTypeOAuth, + Credentials: map[string]any{"api_key": "sk-leak"}, + } + require.Equal(t, "", notAPIKey.GetOpenAIProtocolAPIKey()) + + // openai 原生账号行为不变 + openai := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-openai"}, + } + require.Equal(t, "sk-openai", openai.GetOpenAIProtocolAPIKey()) +} + +// TestBuildUpstreamModelsRequest_CNProviders 验证“同步上游支持的模型”对国产供应商可用: +// 密钥经 GetOpenAIProtocolAPIKey 读取,/models 端点拼接到账号 base_url(含默认值)。 +func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) { + t.Parallel() + + svc := &AccountTestService{cfg: &config.Config{}} + cases := []struct { + name string + platform string + mode string + wantURL string + }{ + {"kimi default", PlatformKimi, "", "https://api.moonshot.cn/v1/models"}, + {"kimi coding", PlatformKimi, AccountModeCoding, "https://api.kimi.com/coding/v1/models"}, + {"zhipu default", PlatformZhipu, "", "https://open.bigmodel.cn/api/paas/v4/models"}, + {"zhipu coding", PlatformZhipu, AccountModeCoding, "https://open.bigmodel.cn/api/coding/paas/v4/models"}, + {"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + creds := map[string]any{"api_key": "sk-test"} + if tc.mode != "" { + creds["account_mode"] = tc.mode + } + account := &Account{ID: 1, Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: creds} + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, tc.wantURL, req.URL.String()) + require.Equal(t, "Bearer sk-test", req.Header.Get("Authorization")) + }) + } +} + +// TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵: +// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。 +func TestGetAPIProtocol(t *testing.T) { + t.Parallel() + + mk := func(platform, protocol string) *Account { + creds := map[string]any{"api_key": "sk-test"} + if protocol != "" { + creds["api_protocol"] = protocol + } + return &Account{Platform: platform, Type: AccountTypeAPIKey, Credentials: creds} + } + + require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "").GetAPIProtocol(), "缺失回退默认") + require.Equal(t, APIProtocolAnthropic, mk(PlatformZhipu, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点") + require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") + require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") + require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认") +} + +func TestAdaptiveProtocolBaseURLs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + platform string + mode string + wantChat string + wantAnthropic string + wantResponses string + }{ + {"kimi payg", PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL, DefaultKimiPayGAnthropicBaseURL, DefaultKimiPayGBaseURL}, + {"kimi coding", PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL, DefaultKimiCodingAnthropicBaseURL, DefaultKimiCodingBaseURL}, + {"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL}, + {"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL}, + {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := &Account{Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + "account_mode": tc.mode, + }} + require.Equal(t, tc.wantChat, account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + require.Equal(t, tc.wantAnthropic, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + require.Equal(t, tc.wantResponses, account.GetCNProtocolBaseURL(APIProtocolResponses)) + require.Equal(t, tc.wantAnthropic, account.GetAnthropicProtocolBaseURL()) + }) + } +} + +func TestAdaptiveProtocolBaseURLOverrides(t *testing.T) { + t.Parallel() + + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + "base_url": "https://legacy-chat.example.com", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://chat.example.com", + APIProtocolAnthropic: "https://anthropic.example.com", + APIProtocolResponses: "https://responses.example.com", + }, + }} + + require.Equal(t, "https://chat.example.com", account.GetOpenAIBaseURL()) + require.Equal(t, "https://chat.example.com", account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + require.Equal(t, "https://anthropic.example.com", account.GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://responses.example.com", account.GetCNProtocolBaseURL(APIProtocolResponses)) +} + +// TestAnthropicProtocolBaseURL 验证 Anthropic 协议默认端点与协议感知的 +// OpenAI 格式 base 回退。 +func TestAnthropicProtocolBaseURL(t *testing.T) { + t.Parallel() + + // 默认端点(按供应商 × 模式) + require.Equal(t, "https://api.moonshot.cn/anthropic", (&Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://api.kimi.com/coding", (&Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic, "account_mode": AccountModeCoding}, + }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://open.bigmodel.cn/api/anthropic", (&Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://api.deepseek.com/anthropic", (&Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) + + // 凭证 base_url 覆盖默认值 + require.Equal(t, "https://custom.example.com/anthropic", (&Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic, "base_url": "https://custom.example.com/anthropic"}, + }).GetAnthropicProtocolBaseURL()) + + // 非 Anthropic 协议返回空串 + require.Empty(t, (&Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://open.bigmodel.cn/api/paas/v4"}, + }).GetAnthropicProtocolBaseURL()) +} + +// TestGetOpenAIFormatBaseURL_ProtocolAware anthropic 协议账号的凭证 base_url +// 指向 Anthropic 端点,OpenAI 格式路径(模型同步等)必须回退到 CC 默认 base。 +func TestGetOpenAIFormatBaseURL_ProtocolAware(t *testing.T) { + t.Parallel() + + zhipuAnthropic := &Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_protocol": APIProtocolAnthropic, + "base_url": "https://open.bigmodel.cn/api/anthropic", + }, + } + require.Equal(t, "https://open.bigmodel.cn/api/paas/v4", zhipuAnthropic.GetOpenAIFormatBaseURL()) + + kimiCodingAnthropic := &Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_protocol": APIProtocolAnthropic, + "account_mode": AccountModeCoding, + "base_url": "https://api.kimi.com/coding", + }, + } + require.Equal(t, "https://api.kimi.com/coding/v1", kimiCodingAnthropic.GetOpenAIFormatBaseURL()) + + // chat_completions 协议下行为不变(凭证 base_url 原样返回) + ccAccount := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://ds-relay.example.com"}, + } + require.Equal(t, "https://ds-relay.example.com", ccAccount.GetOpenAIFormatBaseURL()) +} + +// TestBuildUpstreamModelsRequest_AnthropicProtocol 模型同步使用协议感知 base。 +func TestBuildUpstreamModelsRequest_AnthropicProtocol(t *testing.T) { + t.Parallel() + svc := &AccountTestService{cfg: &config.Config{}} + account := &Account{ + ID: 1, Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://open.bigmodel.cn/api/anthropic", + }, + } + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/models", req.URL.String()) +} + +// TestBuildOpenAIResponsesURLForPlatform deepseek 官方端点为 /responses(无 /v1)。 +func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) { + t.Parallel() + require.Equal(t, "https://api.deepseek.com/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://api.deepseek.com")) + require.Equal(t, "https://relay.example.com/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com")) + require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1")) + require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com")) + require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4")) +} + +// TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、 +// 清除 previous_response_id;非 deepseek responses 协议原样返回。 +func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { + t.Parallel() + + deepseekResponses := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolResponses}, + } + body := []byte(`{"model":"deepseek-v4-pro","store":true,"previous_response_id":"resp_123","input":"hi"}`) + normalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, body) + require.False(t, gjson.GetBytes(normalized, "store").Bool()) + require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) + require.Equal(t, "deepseek-v4-pro", gjson.GetBytes(normalized, "model").String()) + + deepseekAdaptive := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAdaptive}, + } + adaptiveNormalized := normalizeDeepSeekResponsesRequestBody(deepseekAdaptive, body) + require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool()) + require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists()) + + // 非 responses 协议(deepseek CC 账号)原样返回 + deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) + + // openai 账号原样返回 + openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body))) +} + +// TestGetAnthropicAPIKeyAuthScheme_CNProvider CN 账号可经 extra 覆写鉴权方案, +// 默认保持 x-api-key。 +func TestGetAnthropicAPIKeyAuthScheme_CNProvider(t *testing.T) { + t.Parallel() + + zhipu := &Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + } + require.Equal(t, AnthropicAPIKeyAuthSchemeXAPIKey, zhipu.GetAnthropicAPIKeyAuthScheme()) + + zhipu.Extra = map[string]any{"anthropic_apikey_auth_scheme": "authorization_bearer"} + require.Equal(t, AnthropicAPIKeyAuthSchemeAuthorizationBearer, zhipu.GetAnthropicAPIKeyAuthScheme()) +} diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index a008a3e9fa..1201dad5e4 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -23,8 +23,19 @@ const ( CompositeRouteSourceExplicit = "route" CompositeRouteSourceDetector = "detector" + CompositeRouteSourceAccount = "account_model" ) +// CompositeModelOwnership identifies the concrete provider that exposes a +// public model through an account-level exact mapping. +type CompositeModelOwnership struct { + TargetPlatform string + Matched bool + Ambiguous bool +} + +type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error) + var ( ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found") ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists") diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index b5def4ff54..c7736f9582 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -106,6 +106,12 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformGemini, true case "xai", "x-ai", "grok": return PlatformGrok, true + case "kimi", "moonshot": + return PlatformKimi, true + case "zhipu", "glm", "bigmodel": + return PlatformZhipu, true + case "deepseek": + return PlatformDeepseek, true } if rest != "" { normalized = strings.TrimPrefix(rest, "models/") @@ -133,6 +139,15 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformGemini, true case normalized == "grok" || strings.HasPrefix(normalized, "grok-"): return PlatformGrok, true + case normalized == "k3", + normalized == "k3-256k", + strings.HasPrefix(normalized, "kimi-"), + strings.HasPrefix(normalized, "moonshot-"): + return PlatformKimi, true + case strings.HasPrefix(normalized, "glm-"): + return PlatformZhipu, true + case strings.HasPrefix(normalized, "deepseek-"): + return PlatformDeepseek, true default: return "", false } @@ -179,7 +194,8 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou func isConcreteRequestPlatform(platform string) bool { switch platform { - case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok: + case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek: return true default: return false diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 290fc4ac0e..bff5928024 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -8,6 +8,150 @@ import ( "github.com/stretchr/testify/require" ) +type compositeOwnershipAccountRepo struct { + AccountRepository + accounts []Account +} + +func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) { + return r.accounts, nil +} + +// Scenario: 唯一平台的精确别名可路由 +func TestResolveCompositeModelOwnershipKeepsProviderAccountsIsolated(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-public": "gpt-5"}, + }, + }, + { + ID: 2, + Platform: PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}, + }, + }, + }, + } + svc := &GatewayService{accountRepo: repo} + + deepSeekOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "reasoning-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, deepSeekOwnership) + + openAIOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-public") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, openAIOwnership) +} + +// Scenario: 通配符和空映射不声明所有权 +func TestResolveCompositeModelOwnershipRequiresNonEmptyExactMappings(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"*": "gpt-5", "gpt-*": "gpt-5", "empty-alias": ""}, + }, + }, + { + ID: 2, + Platform: PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-public": "grok-4"}, + }, + }, + }, + } + svc := &GatewayService{accountRepo: repo} + + for _, model := range []string{"gpt-5", "empty-alias", "unknown-alias"} { + ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, model) + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{}, ownership, "model=%s", model) + } + + ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "grok-public") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformGrok, Matched: true}, ownership) +} + +func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatformAliases(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + {ID: 1, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5", "ambiguous": "gpt-5"}}}, + {ID: 2, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5.1"}}}, + {ID: 3, Platform: PlatformDeepseek, Credentials: map[string]any{"model_mapping": map[string]any{"ambiguous": "deepseek-v4-pro"}}}, + }, + } + svc := &GatewayService{accountRepo: repo} + + samePlatform, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "shared-openai") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, samePlatform) + + ambiguous, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "ambiguous") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous) +} + +func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{{ + ID: 1, + Platform: PlatformDeepseek, + Credentials: map[string]any{"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}}, + }}, + } + resolver := NewCompositeRouteResolver(nil) + svc := NewGatewayService( + repo, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + resolver, + nil, + nil, + ) + require.Same(t, resolver, svc.compositeResolver) + + decision, err := resolver.Resolve(context.Background(), groupID, "reasoning-alias", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) +} + func TestDetectModelPlatform(t *testing.T) { tests := []struct { name string @@ -25,6 +169,14 @@ func TestDetectModelPlatform(t *testing.T) { {name: "learnlm", model: "learnlm-2.0-flash-experimental", platform: PlatformGemini, ok: true}, {name: "grok", model: "grok-4", platform: PlatformGrok, ok: true}, {name: "xai prefix", model: "xai/grok-4", platform: PlatformGrok, ok: true}, + {name: "kimi", model: "kimi-k2-thinking", platform: PlatformKimi, ok: true}, + {name: "kimi code bare k3", model: "K3", platform: PlatformKimi, ok: true}, + {name: "kimi code bare k3 256k", model: "k3-256k", platform: PlatformKimi, ok: true}, + {name: "kimi code provider prefix", model: "kimi-code/k3", platform: PlatformKimi, ok: true}, + {name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true}, + {name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true}, + {name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true}, + {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } @@ -59,7 +211,14 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}, platforms, ) } + +func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + require.True(t, isConcreteRequestPlatform(platform)) + require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform)) + } +} diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index 3d69f13553..f2e1928ace 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -8,13 +8,20 @@ import ( ) type CompositeRouteResolver struct { - repo CompositeModelRouteRepository + repo CompositeModelRouteRepository + modelOwnershipResolver CompositeModelOwnershipResolver } func NewCompositeRouteResolver(repo CompositeModelRouteRepository) *CompositeRouteResolver { return &CompositeRouteResolver{repo: repo} } +func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeModelOwnershipResolver) { + if r != nil { + r.modelOwnershipResolver = resolver + } +} + func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) { model = strings.TrimSpace(model) endpoint = normalizeCompositeRouteEndpoint(endpoint) @@ -51,6 +58,35 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod } } + if r != nil && r.modelOwnershipResolver != nil && groupID > 0 { + ownership, err := r.modelOwnershipResolver(ctx, groupID, model) + if err != nil { + // A recognizable model can still use the existing detector when the + // account catalog is temporarily unavailable. Unknown aliases cannot. + if _, detectable := DetectModelPlatform(model); !detectable { + return decision, fmt.Errorf("resolve account model ownership: %w", err) + } + } else if ownership.Ambiguous { + decision.Reason = "model is exposed by multiple provider platforms" + return decision, nil + } else if ownership.Matched { + platform := strings.TrimSpace(ownership.TargetPlatform) + if !isConcreteRequestPlatform(platform) { + decision.Reason = "account model ownership has no concrete target platform" + return decision, nil + } + return CompositeRouteDecision{ + Matched: true, + Source: CompositeRouteSourceAccount, + GroupID: groupID, + PublicModel: model, + TargetPlatform: platform, + UpstreamModel: model, + Endpoint: endpoint, + }, nil + } + } + if platform, ok := DetectModelPlatform(model); ok { return CompositeRouteDecision{ Matched: true, diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 35028934dd..474b29387b 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "testing" "github.com/stretchr/testify/require" @@ -69,6 +70,98 @@ func TestCompositeRouteResolverExplicitExactRouteRewritesModel(t *testing.T) { require.Equal(t, int64(10), decision.Route.ID) } +// Scenario: 唯一平台的精确别名可路由 +func TestCompositeRouteResolverUsesAccountModelOwnershipForUnprefixedAlias(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(_ context.Context, groupID int64, model string) (CompositeModelOwnership, error) { + require.Equal(t, int64(7), groupID) + require.Equal(t, "reasoning-alias", model) + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) + require.Equal(t, "reasoning-alias", decision.UpstreamModel) +} + +func TestCompositeRouteResolverAccountOwnershipOverridesBuiltInDetector(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) +} + +// Scenario: 显式路由保持最高优先级 +func TestCompositeRouteResolverExplicitRouteBeatsAccountOwnership(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{{ + ID: 10, + GroupID: 7, + PublicModel: "reasoning-alias", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "gpt-5", + Endpoint: CompositeRouteEndpointAny, + Enabled: true, + }}, + }) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceExplicit, decision.Source) + require.Equal(t, PlatformOpenAI, decision.TargetPlatform) + require.Equal(t, "gpt-5", decision.UpstreamModel) +} + +// Scenario: 跨平台同名别名不被猜测 +func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{Ambiguous: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "shared-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.False(t, decision.Matched) + require.Empty(t, decision.TargetPlatform) + require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason) +} + +func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) { + lookupErr := errors.New("account catalog unavailable") + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{}, lookupErr + }) + + detected, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, detected.Matched) + require.Equal(t, CompositeRouteSourceDetector, detected.Source) + require.Equal(t, PlatformOpenAI, detected.TargetPlatform) + + unknown, err := resolver.Resolve(context.Background(), 7, "company-model", CompositeRouteEndpointResponses) + require.ErrorIs(t, err, lookupErr) + require.False(t, unknown.Matched) +} + func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ @@ -191,6 +284,22 @@ func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *te require.Nil(t, decision.Route) } +func TestCompositeRouteResolverDetectsKimiCodeBareModels(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + + for _, model := range []string{"k3", "k3-256k", "kimi-code/k3"} { + t.Run(model, func(t *testing.T) { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceDetector, decision.Source) + require.Equal(t, PlatformKimi, decision.TargetPlatform) + require.Equal(t, model, decision.UpstreamModel) + }) + } +} + func TestCompositeRouteResolverExplicitRoutesCoverBucketTwoProviders(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ diff --git a/backend/internal/service/custom_channel_time_pricing.go b/backend/internal/service/custom_channel_time_pricing.go new file mode 100644 index 0000000000..5b7ce8eb3d --- /dev/null +++ b/backend/internal/service/custom_channel_time_pricing.go @@ -0,0 +1,143 @@ +package service + +import ( + "fmt" + "math" + "sort" + "strings" + "sync" + "time" +) + +var channelTimePricingLocations sync.Map + +type parsedChannelTimePeriod struct { + start int + end int + multiplier float64 +} + +// validateChannelTimePricing 校验分时倍率配置。nil 或空 periods 表示未启用。 +func validateChannelTimePricing(config *ChannelTimePricing) error { + if config == nil || len(config.Periods) == 0 { + return nil + } + if _, err := loadChannelTimePricingLocation(config.Timezone); err != nil { + return fmt.Errorf("timezone: %w", err) + } + _, err := parseChannelTimePeriods(config.Periods) + return err +} + +// MultiplierAt 返回 at 对应的分时倍率。无配置或脏配置均安全降级为 1。 +func (config *ChannelTimePricing) MultiplierAt(at time.Time) float64 { + if config == nil || len(config.Periods) == 0 || at.IsZero() { + return 1.0 + } + if err := validateChannelTimePricing(config); err != nil { + return 1.0 + } + location, err := loadChannelTimePricingLocation(config.Timezone) + if err != nil { + return 1.0 + } + periods, err := parseChannelTimePeriods(config.Periods) + if err != nil { + return 1.0 + } + + local := at.In(location) + if config.WeekdaysOnly && (local.Weekday() == time.Saturday || local.Weekday() == time.Sunday) { + return 1.0 + } + second := local.Hour()*60*60 + local.Minute()*60 + local.Second() + for _, period := range periods { + if second >= period.start && second < period.end { + return period.multiplier + } + } + return 1.0 +} + +func loadChannelTimePricingLocation(name string) (*time.Location, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("timezone is required") + } + if name == "Local" { + return nil, fmt.Errorf("local is not a supported timezone") + } + if cached, ok := channelTimePricingLocations.Load(name); ok { + location, valid := cached.(*time.Location) + if valid && location != nil { + return location, nil + } + channelTimePricingLocations.Delete(name) + } + location, err := time.LoadLocation(name) + if err != nil { + return nil, err + } + actual, _ := channelTimePricingLocations.LoadOrStore(name, location) + actualLocation, ok := actual.(*time.Location) + if !ok || actualLocation == nil { + return nil, fmt.Errorf("invalid cached timezone %q", name) + } + return actualLocation, nil +} + +func parseChannelTime(value string, end bool) (int, error) { + if end && (value == "00:00" || value == "00:00:00") { + return 24 * 60 * 60, nil + } + layout := "15:04:05" + if len(value) == len("15:04") { + layout = "15:04" + } + parsed, err := time.Parse(layout, value) + if err != nil || parsed.Format(layout) != value { + return 0, fmt.Errorf("time %q must use HH:mm or HH:mm:ss format", value) + } + return parsed.Hour()*60*60 + parsed.Minute()*60 + parsed.Second(), nil +} + +func parseChannelTimePeriods(periods []ChannelTimePricingPeriod) ([]parsedChannelTimePeriod, error) { + parsed := make([]parsedChannelTimePeriod, 0, len(periods)) + for _, period := range periods { + if math.IsNaN(period.Multiplier) || math.IsInf(period.Multiplier, 0) || period.Multiplier <= 0 { + return nil, fmt.Errorf("multiplier must be finite and greater than 0") + } + if period.Multiplier < 0.01 { + return nil, fmt.Errorf("multiplier must be at least 0.01") + } + scaled := period.Multiplier * 100 + if math.IsNaN(scaled) || math.IsInf(scaled, 0) { + return nil, fmt.Errorf("multiplier must remain finite when scaled") + } + if math.Abs(scaled-math.Round(scaled)) > 1e-9 { + return nil, fmt.Errorf("multiplier must have at most two decimal places") + } + + start, err := parseChannelTime(period.StartTime, false) + if err != nil { + return nil, err + } + end, err := parseChannelTime(period.EndTime, true) + if err != nil { + return nil, err + } + if period.StartTime == period.EndTime || start >= end { + return nil, fmt.Errorf("start time must be before end time") + } + parsed = append(parsed, parsedChannelTimePeriod{start: start, end: end, multiplier: period.Multiplier}) + } + + sort.Slice(parsed, func(i, j int) bool { + return parsed[i].start < parsed[j].start + }) + for i := 1; i < len(parsed); i++ { + if parsed[i].start < parsed[i-1].end { + return nil, fmt.Errorf("time pricing periods overlap") + } + } + return parsed, nil +} diff --git a/backend/internal/service/custom_channel_time_pricing_test.go b/backend/internal/service/custom_channel_time_pricing_test.go new file mode 100644 index 0000000000..f36c19b7c9 --- /dev/null +++ b/backend/internal/service/custom_channel_time_pricing_test.go @@ -0,0 +1,190 @@ +//go:build unit + +package service + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func timeConfig(periods ...ChannelTimePricingPeriod) *ChannelTimePricing { + return &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: periods} +} + +func onePeriod() []ChannelTimePricingPeriod { + return []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}} +} + +func TestValidateChannelTimePricing(t *testing.T) { + tests := []struct { + name string + config *ChannelTimePricing + wantErr string + }{ + {name: "nil disabled", config: nil}, + {name: "empty disabled", config: &ChannelTimePricing{Timezone: "Asia/Shanghai"}}, + {name: "adjacent", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "12:00", EndTime: "14:00", Multiplier: 1.5})}, + {name: "midnight split", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "02:00", Multiplier: 2})}, + {name: "second precision", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00:00", EndTime: "12:00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "14:00:00", EndTime: "18:00:00", Multiplier: 2})}, + {name: "second precision overlap", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00:00", EndTime: "12:00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "11:59:59", EndTime: "14:00:00", Multiplier: 2}), wantErr: "overlap"}, + {name: "empty timezone", config: &ChannelTimePricing{Periods: onePeriod()}, wantErr: "timezone"}, + {name: "whitespace timezone", config: &ChannelTimePricing{Timezone: " ", Periods: onePeriod()}, wantErr: "timezone"}, + {name: "timezone", config: &ChannelTimePricing{Timezone: "UTC+8", Periods: onePeriod()}, wantErr: "timezone"}, + {name: "format", config: timeConfig(ChannelTimePricingPeriod{StartTime: "9:00", EndTime: "12:00", Multiplier: 2}), wantErr: "HH:mm"}, + {name: "equal midnight", config: timeConfig(ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "00:00", Multiplier: 2}), wantErr: "before"}, + {name: "cross midnight", config: timeConfig(ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "02:00", Multiplier: 2}), wantErr: "before"}, + {name: "overlap", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "11:59", EndTime: "14:00", Multiplier: 2}), wantErr: "overlap"}, + {name: "zero", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0}), wantErr: "greater than 0"}, + {name: "minimum positive", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0.01})}, + {name: "tiny positive", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 1e-12}), wantErr: "at least 0.01"}, + {name: "below minimum", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0.001}), wantErr: "at least 0.01"}, + {name: "three decimals", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 1.001}), wantErr: "decimal"}, + {name: "scaled overflow", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: math.MaxFloat64}), wantErr: "finite"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChannelTimePricing(tt.config) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), tt.wantErr), "error %q does not contain %q", err, tt.wantErr) + }) + } +} + +func TestChannelTimePricingMultiplierAt(t *testing.T) { + config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}) + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "Shanghai 08:59", at: time.Date(2026, 6, 29, 0, 59, 0, 0, time.UTC), want: 1}, + {name: "Shanghai 09:00", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2}, + {name: "Shanghai 11:59", at: time.Date(2026, 6, 29, 3, 59, 0, 0, time.UTC), want: 2}, + {name: "Shanghai 12:00", at: time.Date(2026, 6, 29, 4, 0, 0, 0, time.UTC), want: 1}, + {name: "Shanghai 14:00", at: time.Date(2026, 6, 29, 6, 0, 0, 0, time.UTC), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } + + newYork := &ChannelTimePricing{Timezone: "America/New_York", Periods: onePeriod()} + at := time.Date(2026, 6, 29, 14, 0, 0, 0, time.UTC) + require.Equal(t, 1.0, config.MultiplierAt(at)) + require.Equal(t, 2.0, newYork.MultiplierAt(at)) +} + +func TestChannelTimePricingMultiplierAtWeekdaysOnly(t *testing.T) { + config := &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, + Periods: onePeriod(), + } + + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "Monday in configured timezone", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2}, + {name: "Saturday in configured timezone", at: time.Date(2026, 7, 4, 1, 0, 0, 0, time.UTC), want: 1}, + {name: "Sunday in configured timezone", at: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + +func TestChannelTimePricingMultiplierAtSecondPrecision(t *testing.T) { + config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00:30", EndTime: "09:00:45", Multiplier: 2}) + shanghai, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "before", at: time.Date(2026, 6, 29, 9, 0, 29, 0, shanghai), want: 1}, + {name: "start", at: time.Date(2026, 6, 29, 9, 0, 30, 0, shanghai), want: 2}, + {name: "last matching second", at: time.Date(2026, 6, 29, 9, 0, 44, 999_999_999, shanghai), want: 2}, + {name: "end", at: time.Date(2026, 6, 29, 9, 0, 45, 0, shanghai), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + +func TestChannelTimePricingMultiplierAtMidnightSplit(t *testing.T) { + config := timeConfig( + ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "02:00", Multiplier: 3}, + ) + shanghai, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "23:59", at: time.Date(2026, 6, 29, 23, 59, 0, 0, shanghai), want: 2}, + {name: "next day 00:00", at: time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai), want: 3}, + {name: "02:00", at: time.Date(2026, 6, 30, 2, 0, 0, 0, shanghai), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + +func TestChannelTimePricingMultiplierAtDegradesForInvalidConfigurations(t *testing.T) { + var nilConfig *ChannelTimePricing + zeroTime := time.Time{} + validAt := time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC) + + require.Equal(t, 1.0, nilConfig.MultiplierAt(validAt)) + require.Equal(t, 1.0, timeConfig().MultiplierAt(validAt)) + require.Equal(t, 1.0, timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}).MultiplierAt(zeroTime)) + require.Equal(t, 1.0, (&ChannelTimePricing{Periods: onePeriod()}).MultiplierAt(validAt)) + require.Equal(t, 1.0, (&ChannelTimePricing{Timezone: " ", Periods: onePeriod()}).MultiplierAt(validAt)) + require.Equal(t, 1.0, (&ChannelTimePricing{Timezone: "UTC+8", Periods: onePeriod()}).MultiplierAt(validAt)) + require.Equal(t, 1.0, timeConfig(ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "02:00", Multiplier: 2}).MultiplierAt(validAt)) +} + +func TestChannelTimePricingRejectsLocalTimezone(t *testing.T) { + config := &ChannelTimePricing{Timezone: "Local", Periods: onePeriod()} + + err := validateChannelTimePricing(config) + require.Error(t, err) + require.Contains(t, err.Error(), "timezone") + require.Equal(t, 1.0, config.MultiplierAt(time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC))) +} diff --git a/backend/internal/service/custom_group_usage_rollup.go b/backend/internal/service/custom_group_usage_rollup.go new file mode 100644 index 0000000000..d14dc9bfd9 --- /dev/null +++ b/backend/internal/service/custom_group_usage_rollup.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "time" + + appTimezone "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +const groupUsageDateFormat = "2006-01-02" + +// GroupUsageRollupRepository 是分组日汇总的可选持久化能力。 +type GroupUsageRollupRepository interface { + SyncGroupUsageRollups(ctx context.Context, todayStart time.Time) error +} + +// GroupUsageTimezoneName 返回服务端配置的时区名称。 +func GroupUsageTimezoneName() string { + return appTimezone.Location().String() +} + +// GroupUsageTodayStart 返回指定时刻在服务端配置时区内的自然日 UTC 起点。 +func GroupUsageTodayStart(at time.Time) time.Time { + return appTimezone.StartOfDay(at).UTC() +} + +// GroupUsageYesterdayStart 返回指定时刻前一个本地日历日的 UTC 起点。 +func GroupUsageYesterdayStart(at time.Time) time.Time { + return appTimezone.StartOfDay(at).AddDate(0, 0, -1).UTC() +} + +// GroupUsageDate 返回指定时刻在服务端配置时区内的日期。 +func GroupUsageDate(at time.Time) string { + return at.In(appTimezone.Location()).Format(groupUsageDateFormat) +} + +// ParseGroupUsageDate 解析服务端配置时区内的日期并返回其零点。 +func ParseGroupUsageDate(value string) (time.Time, error) { + return appTimezone.ParseInLocation(groupUsageDateFormat, value) +} diff --git a/backend/internal/service/custom_group_usage_rollup_test.go b/backend/internal/service/custom_group_usage_rollup_test.go new file mode 100644 index 0000000000..35c568a456 --- /dev/null +++ b/backend/internal/service/custom_group_usage_rollup_test.go @@ -0,0 +1,50 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + appTimezone "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +func useGroupUsageTestTimezone(t *testing.T, name string) { + t.Helper() + + previousName := appTimezone.Name() + require.NoError(t, appTimezone.Init(name)) + t.Cleanup(func() { require.NoError(t, appTimezone.Init(previousName)) }) +} + +func TestGroupUsageDateUsesConfiguredTimezoneBoundary(t *testing.T) { + useGroupUsageTestTimezone(t, "America/New_York") + + beforeMidnight := time.Date(2026, 3, 9, 3, 59, 59, 0, time.UTC) + atMidnight := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + + require.Equal(t, "2026-03-08", GroupUsageDate(beforeMidnight)) + require.Equal(t, "2026-03-09", GroupUsageDate(atMidnight)) + require.Equal(t, atMidnight, GroupUsageTodayStart(atMidnight)) +} + +func TestGroupUsageParseDateUsesConfiguredTimezone(t *testing.T) { + useGroupUsageTestTimezone(t, "America/New_York") + + parsed, err := ParseGroupUsageDate("2026-03-08") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC), parsed.UTC()) + require.Equal(t, "America/New_York", parsed.Location().String()) +} + +func TestGroupUsageYesterdayStartHandlesDST(t *testing.T) { + useGroupUsageTestTimezone(t, "America/New_York") + + todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + yesterdayStart := GroupUsageYesterdayStart(todayStart) + + require.Equal(t, time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC), yesterdayStart) + require.Equal(t, 23*time.Hour, todayStart.Sub(yesterdayStart)) + require.Equal(t, "America/New_York", GroupUsageTimezoneName()) +} diff --git a/backend/internal/service/dashboard_aggregation_service.go b/backend/internal/service/dashboard_aggregation_service.go index 5dfa02a824..6e351d75d2 100644 --- a/backend/internal/service/dashboard_aggregation_service.go +++ b/backend/internal/service/dashboard_aggregation_service.go @@ -18,12 +18,14 @@ const ( defaultDashboardAggregationBackfillTimeout = 30 * time.Minute dashboardAggregationRetentionInterval = 6 * time.Hour - // dashboardAggregationLeaderLockKey gates the periodic scheduled aggregation so - // that only one instance runs it per cycle in a multi-replica deployment. + // dashboardAggregationLeaderLockKey 保证多副本部署中每个周期只有一个实例执行聚合。 dashboardAggregationLeaderLockKey = "dashboard:aggregation:leader" - // dashboardAggregationLeaderLockTTL must exceed the job's worst-case runtime - // (defaultDashboardAggregationTimeout) so the lock never expires mid-run. + // TTL 必须覆盖 dashboard 聚合与分组日汇总两个有界阶段,避免任务中途失锁。 dashboardAggregationLeaderLockTTL = 5 * time.Minute + + // 启动回填耗时可能远长于周期聚合,因此使用独立锁并让 TTL 严格大于回填超时。 + dashboardAggregationGroupUsageBackfillLeaderLockKey = "dashboard:aggregation:group-usage-backfill:leader" + dashboardAggregationGroupUsageBackfillLeaderLockTTL = defaultDashboardAggregationBackfillTimeout + time.Minute ) var ( @@ -95,6 +97,7 @@ func (s *DashboardAggregationService) Start() { logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合作业已禁用") return } + go s.runStartupGroupUsageSync() interval := time.Duration(s.cfg.IntervalSeconds) * time.Second if interval <= 0 { @@ -229,6 +232,7 @@ func (s *DashboardAggregationService) runScheduledAggregation() { return } defer release() + defer s.runScheduledGroupUsageSync() now := time.Now().UTC() last, err := s.repo.GetAggregationWatermark(ctx) @@ -269,6 +273,35 @@ func (s *DashboardAggregationService) runScheduledAggregation() { s.maybeCleanupRetention(ctx, now) } +func (s *DashboardAggregationService) runScheduledGroupUsageSync() { + ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationTimeout) + defer cancel() + if err := s.syncGroupUsageRollups(ctx, time.Now().UTC()); err != nil { + logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 分组用量日汇总失败: %v", err) + } +} + +func (s *DashboardAggregationService) runStartupGroupUsageSync() { + ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout) + defer cancel() + release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, dashboardAggregationGroupUsageBackfillLeaderLockKey, s.instanceID, dashboardAggregationGroupUsageBackfillLeaderLockTTL) + if !ok { + return + } + defer release() + if err := s.syncGroupUsageRollups(ctx, time.Now().UTC()); err != nil { + logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 启动分组用量回填失败: %v", err) + } +} + +func (s *DashboardAggregationService) syncGroupUsageRollups(ctx context.Context, now time.Time) error { + repo, ok := s.repo.(GroupUsageRollupRepository) + if !ok { + return nil + } + return repo.SyncGroupUsageRollups(ctx, GroupUsageTodayStart(now)) +} + func (s *DashboardAggregationService) backfillRange(ctx context.Context, start, end time.Time) error { if !atomic.CompareAndSwapInt32(&s.running, 0, 1) { return errDashboardAggregationRunning diff --git a/backend/internal/service/dashboard_aggregation_service_test.go b/backend/internal/service/dashboard_aggregation_service_test.go index fbb671bb66..d164519330 100644 --- a/backend/internal/service/dashboard_aggregation_service_test.go +++ b/backend/internal/service/dashboard_aggregation_service_test.go @@ -24,12 +24,36 @@ type dashboardAggregationRepoTestStub struct { cleanupUsageErr error cleanupDedupErr error ensurePartitionErr error + aggregateCtx context.Context + events *[]string +} + +type dashboardAggregationRollupRepoTestStub struct { + *dashboardAggregationRepoTestStub + groupRollupCalls int + groupRollupAt time.Time + groupRollupErr error + groupRollupCtx context.Context +} + +func (s *dashboardAggregationRollupRepoTestStub) SyncGroupUsageRollups(ctx context.Context, todayStart time.Time) error { + s.groupRollupCalls++ + s.groupRollupAt = todayStart + s.groupRollupCtx = ctx + if s.events != nil { + *s.events = append(*s.events, "group_rollup") + } + return s.groupRollupErr } func (s *dashboardAggregationRepoTestStub) AggregateRange(ctx context.Context, start, end time.Time) error { s.aggregateCalls++ + s.aggregateCtx = ctx s.lastStart = start s.lastEnd = end + if s.events != nil { + *s.events = append(*s.events, "dashboard_aggregation") + } return s.aggregateErr } @@ -88,6 +112,103 @@ func TestDashboardAggregationService_RunScheduledAggregation_EpochUsesRetentionS require.Equal(t, truncateToDayUTC(repo.lastEnd.AddDate(0, 0, -1)), repo.lastStart) } +func TestDashboardAggregationService_RunScheduledAggregationSyncsGroupUsageRollups(t *testing.T) { + baseRepo := &dashboardAggregationRepoTestStub{watermark: time.Now().UTC()} + repo := &dashboardAggregationRollupRepoTestStub{dashboardAggregationRepoTestStub: baseRepo} + svc := &DashboardAggregationService{ + repo: repo, + cfg: config.DashboardAggregationConfig{ + Enabled: true, + IntervalSeconds: 60, + LookbackSeconds: 120, + Retention: config.DashboardAggregationRetentionConfig{ + UsageLogsDays: 1, + UsageBillingDedupDays: 2, + HourlyDays: 1, + DailyDays: 1, + }, + }, + } + + before := GroupUsageTodayStart(time.Now()) + svc.runScheduledAggregation() + after := GroupUsageTodayStart(time.Now()) + + require.Equal(t, 1, repo.groupRollupCalls) + require.Contains(t, []time.Time{before, after}, repo.groupRollupAt) +} + +func TestDashboardAggregationService_RunScheduledAggregationSyncsGroupAfterDashboardEarlyReturn(t *testing.T) { + events := make([]string, 0, 2) + baseRepo := &dashboardAggregationRepoTestStub{ + watermark: time.Now().UTC(), + aggregateErr: errors.New("dashboard aggregation failed"), + events: &events, + } + repo := &dashboardAggregationRollupRepoTestStub{ + dashboardAggregationRepoTestStub: baseRepo, + groupRollupErr: errors.New("group rollup failed"), + } + svc := &DashboardAggregationService{ + repo: repo, + cfg: config.DashboardAggregationConfig{ + LookbackSeconds: 120, + Retention: config.DashboardAggregationRetentionConfig{ + UsageLogsDays: 1, + }, + }, + } + + svc.runScheduledAggregation() + + require.Equal(t, []string{"dashboard_aggregation", "group_rollup"}, events) + require.NotNil(t, repo.aggregateCtx) + require.NotNil(t, repo.groupRollupCtx) + if repo.aggregateCtx == repo.groupRollupCtx { + t.Fatal("分组日汇总必须使用独立于 dashboard 聚合的 context") + } + groupDeadline, ok := repo.groupRollupCtx.Deadline() + require.True(t, ok, "group rollup context must be bounded") + require.LessOrEqual(t, time.Until(groupDeadline), defaultDashboardAggregationTimeout) +} + +type dashboardAggregationLeaderLockRecordingCache struct { + delegate *fakeLeaderLockCache + acquireKeys []string + acquireTTLs []time.Duration +} + +func (c *dashboardAggregationLeaderLockRecordingCache) TryAcquireLeaderLock(ctx context.Context, key, owner string, ttl time.Duration) (bool, error) { + c.acquireKeys = append(c.acquireKeys, key) + c.acquireTTLs = append(c.acquireTTLs, ttl) + return c.delegate.TryAcquireLeaderLock(ctx, key, owner, ttl) +} + +func (c *dashboardAggregationLeaderLockRecordingCache) ReleaseLeaderLock(ctx context.Context, key, owner string) error { + return c.delegate.ReleaseLeaderLock(ctx, key, owner) +} + +func TestDashboardAggregationService_StartupGroupSyncUsesIndependentLongLivedLeaderLock(t *testing.T) { + delegate := &fakeLeaderLockCache{} + _, err := delegate.TryAcquireLeaderLock(context.Background(), dashboardAggregationLeaderLockKey, "periodic-peer", time.Hour) + require.NoError(t, err) + cache := &dashboardAggregationLeaderLockRecordingCache{delegate: delegate} + repo := &dashboardAggregationRollupRepoTestStub{dashboardAggregationRepoTestStub: &dashboardAggregationRepoTestStub{}} + svc := &DashboardAggregationService{ + repo: repo, + lockCache: cache, + instanceID: "startup-instance", + } + + svc.runStartupGroupUsageSync() + + require.Len(t, cache.acquireKeys, 1) + require.NotEqual(t, dashboardAggregationLeaderLockKey, cache.acquireKeys[0]) + require.Len(t, cache.acquireTTLs, 1) + require.Greater(t, cache.acquireTTLs[0], defaultDashboardAggregationBackfillTimeout) + require.Equal(t, 1, repo.groupRollupCalls) +} + func TestDashboardAggregationService_CleanupRetentionFailure_DoesNotRecord(t *testing.T) { repo := &dashboardAggregationRepoTestStub{cleanupAggregatesErr: errors.New("清理失败")} svc := &DashboardAggregationService{ diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index d815ed6ef5..3c656b61f5 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -212,7 +212,7 @@ func (s *DashboardService) GetGroupStatsWithUsageFilters(ctx context.Context, st return s.GetGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType) } -// GetGroupUsageSummary returns today's and cumulative cost for all groups. +// GetGroupUsageSummary returns today's, yesterday's, and cumulative cost for all groups. func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart) if err != nil { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 2d5311bb24..4679823777 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -43,12 +43,59 @@ const ( PlatformGemini = domain.PlatformGemini PlatformAntigravity = domain.PlatformAntigravity PlatformGrok = domain.PlatformGrok - PlatformComposite = domain.PlatformComposite + // 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。 + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek + PlatformComposite = domain.PlatformComposite // PlatformKiro is retained for unsupported-platform threshold tests and legacy // account rows. Scheduling-threshold evaluation never pauses kiro accounts. PlatformKiro = "kiro" ) +// 账号接入模式(国产供应商):按量付费 vs Coding Plan。 +const ( + AccountModePayG = domain.AccountModePayG + AccountModeCoding = domain.AccountModeCoding +) + +// 上游 API 协议(国产供应商):决定转发端点与格式,与接入模式正交。 +const ( + APIProtocolChatCompletions = domain.APIProtocolChatCompletions + APIProtocolAnthropic = domain.APIProtocolAnthropic + APIProtocolResponses = domain.APIProtocolResponses + APIProtocolAdaptive = domain.APIProtocolAdaptive +) + +// 国产 OpenAI 兼容供应商各模式的默认 base_url。 +// 与前端 credentialsBuilder.ts 中的预设保持一致。 +const ( + DefaultKimiPayGBaseURL = "https://api.moonshot.cn/v1" + DefaultKimiCodingBaseURL = "https://api.kimi.com/coding/v1" + DefaultZhipuPayGBaseURL = "https://open.bigmodel.cn/api/paas/v4" + DefaultZhipuCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4" + DefaultDeepseekBaseURL = "https://api.deepseek.com" +) + +// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。 +// 与前端 credentialsBuilder.ts 中的预设保持一致。 +const ( + DefaultKimiPayGAnthropicBaseURL = "https://api.moonshot.cn/anthropic" + DefaultKimiCodingAnthropicBaseURL = "https://api.kimi.com/coding" + DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic" + DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic" +) + +// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。 +func IsCNProvider(platform string) bool { + switch platform { + case PlatformKimi, PlatformZhipu, PlatformDeepseek: + return true + default: + return false + } +} + // AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。 // ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束), // 若新增平台需同步修改该 schema。 @@ -58,14 +105,20 @@ var AllowedQuotaPlatforms = []string{ PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, } // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 -// 仅 openai / anthropic / grok 有原生用量窗口可供评估;其他平台写入阈值无效果。 +// openai/anthropic/grok 有原生用量窗口;kimi/zhipu 的 Coding Plan 同样暴露 5h/weekly +// 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。 var AllowedSchedulingThresholdPlatforms = []string{ PlatformOpenAI, PlatformAnthropic, PlatformGrok, + PlatformKimi, + PlatformZhipu, } // IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。 @@ -425,6 +478,13 @@ const ( // Default false (show rates). Admin endpoints always keep full metrics. SettingKeyChannelMonitorHideThroughput = "channel_monitor_hide_throughput" + // SettingKeyChannelMonitorShowQuota controls whether quota/balance snapshots + // attached to channel monitors (check_mode=quota/quota_probe) are exposed on + // the user-facing monitor APIs and UI. Default false (hidden); parsed + // fail-closed (only the literal "true" enables it). Admin endpoints always + // keep the full snapshots regardless of this flag. + SettingKeyChannelMonitorShowQuota = "channel_monitor_show_quota" + // SettingKeyGrokDefaultTextModel is the fallback Grok text model for empty // request models and built-in Grok aliases (e.g. "grok" → this id). Default grok-4.5. SettingKeyGrokDefaultTextModel = "grok_default_text_model" @@ -457,6 +517,10 @@ const ( // the Model Plaza page (global pricing notes, exchange rate, promotions, ...). SettingKeyModelPlazaDescription = "model_plaza_description" + // SettingKeyPluginManagementEnabled controls sidebar visibility only; it does + // not stop or otherwise change already loaded plugin runtimes. + SettingKeyPluginManagementEnabled = "plugin_management_enabled" + // SettingKeyUpstreamBillingProbeSettings stores the global enable switch and interval // for probing remote Sub2API API-key billing metadata. SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings" @@ -473,6 +537,8 @@ const ( // SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling. SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings" + // SettingKeyOpenAIAPIKeyHealthBreakerSettings stores the opt-in OpenAI pool API-key breaker config. + SettingKeyOpenAIAPIKeyHealthBreakerSettings = "openai_apikey_health_breaker_settings" // ========================= // Stream Timeout Handling diff --git a/backend/internal/service/error_policy_test.go b/backend/internal/service/error_policy_test.go index 147143e8dd..74cbacf9a2 100644 --- a/backend/internal/service/error_policy_test.go +++ b/backend/internal/service/error_policy_test.go @@ -66,6 +66,61 @@ func TestCheckErrorPolicy(t *testing.T) { body: []byte(`"error"`), expected: ErrorPolicySkipped, }, + { + name: "custom_error_codes_excluding_529_skip_global_cooldown", + account: &Account{ + ID: 33, + Type: AccountTypeAPIKey, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + statusCode: 529, + body: []byte(`{"error":{"message":"overloaded"}}`), + expected: ErrorPolicySkipped, + }, + { + name: "pool_mode_skips_global_529_cooldown", + account: &Account{ + ID: 34, + Type: AccountTypeAPIKey, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "pool_mode": true, + }, + }, + statusCode: 529, + body: []byte(`{"error":{"message":"overloaded"}}`), + expected: ErrorPolicySkipped, + }, + { + name: "ordinary_account_uses_global_529_cooldown", + account: &Account{ + ID: 35, + Type: AccountTypeAPIKey, + Platform: PlatformOpenAI, + }, + statusCode: 529, + body: []byte(`{"error":{"message":"overloaded"}}`), + expected: ErrorPolicyMatched, + }, + { + name: "custom_error_codes_including_529_take_precedence", + account: &Account{ + ID: 36, + Type: AccountTypeAPIKey, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(529)}, + }, + }, + statusCode: 529, + body: []byte(`{"error":{"message":"overloaded"}}`), + expected: ErrorPolicyMatched, + }, { name: "temp_unschedulable_hit_returns_temp_unscheduled", account: &Account{ diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go index 37fd709f84..a7b05f175c 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go @@ -14,13 +14,12 @@ func BenchmarkGatewayService_ParseSSEUsage_MessageStart(b *testing.B) { } func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart(b *testing.B) { - svc := &GatewayService{} data := `{"type":"message_start","message":{"usage":{"input_tokens":123,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}}` b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { usage := &ClaudeUsage{} - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) } } @@ -36,13 +35,12 @@ func BenchmarkGatewayService_ParseSSEUsage_MessageDelta(b *testing.B) { } func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta(b *testing.B) { - svc := &GatewayService{} data := `{"type":"message_delta","usage":{"output_tokens":456,"cache_creation_input_tokens":30,"cache_read_input_tokens":7,"cached_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}}}` b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { usage := &ClaudeUsage{} - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) } } diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 4fa52cc9ce..f4656bb15e 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -769,6 +769,32 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBas require.Error(t, err) } +func TestGatewayService_AnthropicAPIKeyPassthrough_StripsDeferredToolCacheControl(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + svc := &GatewayService{cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}} + account := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey} + body := []byte(`{"tools":[{"name":"deferred","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"top-level-deferred","defer_loading":true,"cache_control":{"type":"ephemeral"}},{"name":"ordinary","defer_loading":false,"cache_control":{"type":"ephemeral"}},{"name":"malformed","defer_loading":"true","cache_control":{"type":"ephemeral"}}]}`) + + _, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "k") + require.NoError(t, err) + require.False(t, gjson.GetBytes(wireBody, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(wireBody, "tools.1.cache_control").Exists()) + require.True(t, gjson.GetBytes(wireBody, "tools.2.cache_control").Exists()) + require.True(t, gjson.GetBytes(wireBody, "tools.3.cache_control").Exists()) + + countReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "k") + require.NoError(t, err) + countBody, err := io.ReadAll(countReq.Body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(countBody, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(countBody, "tools.1.cache_control").Exists()) + require.True(t, gjson.GetBytes(countBody, "tools.2.cache_control").Exists()) + require.True(t, gjson.GetBytes(countBody, "tools.3.cache_control").Exists()) +} + func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -1256,11 +1282,10 @@ func TestExtractAnthropicSSEDataLine(t *testing.T) { } func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{} data := `{"type":"message_start","message":{"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":9,"cache_creation":{"ephemeral_5m_input_tokens":3,"ephemeral_1h_input_tokens":4}}}}` - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) require.Equal(t, 12, usage.InputTokens) require.Equal(t, 9, usage.CacheReadInputTokens, "应兼容 cached_tokens 字段") @@ -1270,47 +1295,43 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testin } func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) { - svc := &GatewayService{} - usage := &ClaudeUsage{ - InputTokens: 10, - CacheCreation5mTokens: 2, - CacheCreation1hTokens: 6, - } - data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":1,"ephemeral_1h_input_tokens":0}}}` + usage := &ClaudeUsage{} + start := `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}` + parseSSEUsagePassthrough(start, usage) - svc.parseSSEUsagePassthrough(data, usage) + data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":463184,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}` + + parseSSEUsagePassthrough(data, usage) require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens") require.Equal(t, 5, usage.OutputTokens) - require.Equal(t, 8, usage.CacheCreationInputTokens) + require.Equal(t, 463184, usage.CacheCreationInputTokens) require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens") - require.Equal(t, 1, usage.CacheCreation5mTokens) - require.Equal(t, 6, usage.CacheCreation1hTokens, "message_delta 中 0 值不应覆盖已有 1h 明细") + require.Equal(t, 463184, usage.CacheCreation5mTokens) + require.Equal(t, 0, usage.CacheCreation1hTokens) } func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{InputTokens: 3} - svc.parseSSEUsagePassthrough("", usage) + parseSSEUsagePassthrough("", usage) require.Equal(t, 3, usage.InputTokens) - svc.parseSSEUsagePassthrough("[DONE]", usage) + parseSSEUsagePassthrough("[DONE]", usage) require.Equal(t, 3, usage.InputTokens) - svc.parseSSEUsagePassthrough("not-json", usage) + parseSSEUsagePassthrough("not-json", usage) require.Equal(t, 3, usage.InputTokens) // nil usage 不应 panic - svc.parseSSEUsagePassthrough(`{"type":"message_start"}`, nil) + parseSSEUsagePassthrough(`{"type":"message_start"}`, nil) } func TestGatewayService_ParseSSEUsagePassthrough_FallbackFromUsageNode(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{} data := `{"type":"content_block_delta","usage":{"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":1}}}` - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) require.Equal(t, 6, usage.CacheReadInputTokens) require.Equal(t, 3, usage.CacheCreationInputTokens) diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index c18d5bf047..1da7eea92a 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -296,6 +296,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( UpstreamModel: input.RequestModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: input.RequestStream, Duration: time.Since(input.StartTime), FirstTokenMs: firstTokenMs, @@ -310,6 +311,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( body []byte, token string, ) (*http.Request, []byte, error) { + body = stripDeferredToolCacheControl(body) targetURL := claudeAPIURL baseURL := account.GetBaseURL() if baseURL != "" { @@ -546,7 +548,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } - s.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) } else { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") { @@ -625,7 +627,9 @@ func extractAnthropicSSEDataLine(line string) (string, bool) { return line[start:], true } -func (s *GatewayService) parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { +// parseSSEUsagePassthrough 从 Anthropic SSE data 行提取 usage(包级函数: +// Anthropic 平台 passthrough 与国产供应商原生 Anthropic 直通共用)。 +func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { if usage == nil || data == "" || data == "[DONE]" { return } @@ -665,10 +669,10 @@ func (s *GatewayService) parseSSEUsagePassthrough(data string, usage *ClaudeUsag cc5m := deltaUsage.Get("cache_creation.ephemeral_5m_input_tokens") cc1h := deltaUsage.Get("cache_creation.ephemeral_1h_input_tokens") - if cc5m.Exists() && cc5m.Int() > 0 { + if cc5m.Exists() { usage.CacheCreation5mTokens = int(cc5m.Int()) } - if cc1h.Exists() && cc1h.Int() > 0 { + if cc1h.Exists() { usage.CacheCreation1hTokens = int(cc1h.Int()) } } @@ -694,6 +698,72 @@ func (s *GatewayService) parseSSEUsagePassthrough(data string, usage *ClaudeUsag usage.CacheCreationInputTokens = int(total) } } + + // Kimi's Anthropic-compatible stream uses input_tokens with two meanings: + // message_start reports total prompt input, while message_delta reports only + // uncached input. prompt_tokens remains the total in both events. Normalize + // to ClaudeUsage's mutually-exclusive buckets so downstream billing does not + // subtract cache tokens from an already-uncached value. + usageNode := parsed.Get("usage") + if parsed.Get("type").String() == "message_start" { + usageNode = parsed.Get("message.usage") + } + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) +} + +// normalizeAnthropicCompatiblePromptUsage converts provider-native OpenAI-style +// prompt/cache fields into Claude's mutually-exclusive usage buckets. Native +// Anthropic responses do not expose these aliases and are left alone. +func normalizeAnthropicCompatiblePromptUsage(usageNode gjson.Result, usage *ClaudeUsage) bool { + if usage == nil || !usageNode.Exists() { + return false + } + promptTokens := usageNode.Get("prompt_tokens") + promptCacheHitTokens := usageNode.Get("prompt_cache_hit_tokens") + promptCacheMissTokens := usageNode.Get("prompt_cache_miss_tokens") + if (!promptTokens.Exists() || promptTokens.Int() <= 0) && + !promptCacheHitTokens.Exists() && !promptCacheMissTokens.Exists() { + return false + } + + cacheReadTokens := usage.CacheReadInputTokens + if v := usageNode.Get("cache_read_input_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + if cacheReadTokens == 0 { + if v := usageNode.Get("cached_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + } + if cacheReadTokens == 0 { + if v := usageNode.Get("prompt_tokens_details.cached_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + } + if cacheReadTokens == 0 && promptCacheHitTokens.Exists() { + cacheReadTokens = max(int(promptCacheHitTokens.Int()), 0) + } + + cacheCreationTokens := usage.CacheCreationInputTokens + if v := usageNode.Get("cache_creation_input_tokens"); v.Exists() { + cacheCreationTokens = int(v.Int()) + } + if cacheCreationTokens == 0 { + cc5m := usageNode.Get("cache_creation.ephemeral_5m_input_tokens").Int() + cc1h := usageNode.Get("cache_creation.ephemeral_1h_input_tokens").Int() + if cc5m > 0 || cc1h > 0 { + cacheCreationTokens = int(cc5m + cc1h) + } + } + + if promptCacheMissTokens.Exists() { + usage.InputTokens = max(int(promptCacheMissTokens.Int()), 0) + } else { + usage.InputTokens = max(int(promptTokens.Int())-cacheReadTokens-cacheCreationTokens, 0) + } + usage.CacheReadInputTokens = cacheReadTokens + usage.CacheCreationInputTokens = cacheCreationTokens + return true } func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { @@ -727,11 +797,16 @@ func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { usage.CacheReadInputTokens = int(cached) } } + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) return usage } -func (s *GatewayService) invalidNonStreamingJSONFailoverError( +// invalidNonStreamingJSONFailoverError 把"上游 2xx 返回非 JSON body"归一为 +// failover 错误(包级函数:Anthropic 平台 passthrough 与国产供应商原生 +// Anthropic 直通共用)。 +func invalidNonStreamingJSONFailoverError( ctx context.Context, + rateLimitService *RateLimitService, resp *http.Response, account *Account, body []byte, @@ -759,11 +834,11 @@ func (s *GatewayService) invalidNonStreamingJSONFailoverError( parseErr, ) - if s.rateLimitService != nil && account != nil { + if rateLimitService != nil && account != nil { if len(requestedModel) > 0 { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0]) + rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0]) } else { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) } } @@ -798,7 +873,7 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough( if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { var raw json.RawMessage if err := json.Unmarshal(body, &raw); err != nil { - return nil, s.invalidNonStreamingJSONFailoverError(ctx, resp, account, body, err) + return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err) } } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index bdeb2c600b..b7ab3e9429 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -650,6 +651,51 @@ func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t * "count_tokens API-key + 客户端未带 beta token → body strip") } +func TestBuildCountTokensRequest_StripsCacheControlOnlyFromLiteralDeferredTools(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"claude-haiku-4-5","messages":[],"tools":[{"name":"deferred","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"ordinary","custom":{"defer_loading":false},"cache_control":{"type":"ephemeral"}},{"name":"string","custom":{"defer_loading":"true"},"cache_control":{"type":"ephemeral"}},{"name":"number","custom":{"defer_loading":1},"cache_control":{"type":"ephemeral"}},{"name":"object","custom":{"defer_loading":{}},"cache_control":{"type":"ephemeral"}}]}`) + + tests := []struct { + name string + account *Account + token string + tokenType string + }{ + { + name: "generic API key", + account: &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, + token: "sk-ant-test", + tokenType: "apikey", + }, + { + name: "recognized Claude Code OAuth without mimicry", + account: &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, + token: "oauth-token", + tokenType: "oauth", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil) + svc := &GatewayService{cfg: &config.Config{}} + + req, wireBody, err := svc.buildCountTokensRequest( + context.Background(), c, tt.account, body, + tt.token, tt.tokenType, "claude-haiku-4-5", false, + ) + require.NoError(t, err) + require.False(t, gjson.GetBytes(wireBody, "tools.0.cache_control").Exists()) + for idx := 1; idx < 5; idx++ { + require.Equal(t, "ephemeral", gjson.GetBytes(wireBody, fmt.Sprintf("tools.%d.cache_control.type", idx)).String()) + } + require.JSONEq(t, string(wireBody), string(readUpstreamBodyForTest(t, req))) + }) + } +} + // count_tokens passthrough preserve 测试 func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 7b66ac9596..003cbb7c1e 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -366,6 +366,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( body []byte, token string, ) (*http.Request, error) { + body = stripDeferredToolCacheControl(body) targetURL := claudeAPICountTokensURL baseURL := account.GetBaseURL() if baseURL != "" { @@ -429,6 +430,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( // buildCountTokensRequest 构建 count_tokens 上游请求 func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, []byte, error) { + body = stripDeferredToolCacheControl(body) // 确定目标 URL targetURL := claudeAPICountTokensURL if account.Type == AccountTypeAPIKey { diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index d10cb1ea3b..17f9228d5d 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" "github.com/gin-gonic/gin" ) @@ -87,11 +88,21 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { } // Forward 转发请求到Claude API -func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { +func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (result *ForwardResult, err error) { startTime := time.Now() if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // Anthropic Fast is requested with speed=fast rather than OpenAI's + // service_tier. Attach it at this shared boundary so passthrough, OAuth and + // partial-stream results all use the same billing and usage-log path. + defer func() { + if result != nil { + if tier := anthropicSpeedServiceTier(account, parsed.Speed, anthropicSpeedModel(parsed, result)); tier != nil { + result.ServiceTier = tier + } + } + }() beginUpstreamResponseModelObservation(c) // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 @@ -800,14 +811,23 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A var firstTokenMs *int var clientDisconnect bool if reqStream { + writerSizeBeforeStream := c.Writer.Size() streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode) if err != nil { var sseErr *sseStreamErrorEventError if errors.As(err, &sseErr) { // 上游 HTTP 200 + SSE 流体内出现 event:error 帧。 - // 保留 StatusCode=403 以兼容既有 failover/客户端响应语义, - // 但补全 ResponseBody 与 ops 上下文,让运维日志能反映上游真实错误。 body := []byte(sseErr.RawData) + semanticStatus := http.StatusForbidden + if c.Writer.Size() == writerSizeBeforeStream && gjson.GetBytes(body, "error.type").String() == "overloaded_error" { + semanticStatus = 529 + syntheticResp := &http.Response{ + StatusCode: semanticStatus, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(body)), + } + s.handleFailoverSideEffects(ctx, syntheticResp, account, reqModel) + } upstreamMsg := sanitizeUpstreamErrorMessage( strings.TrimSpace(extractUpstreamErrorMessage(body)), @@ -826,7 +846,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A Platform: account.Platform, AccountID: account.ID, AccountName: account.Name, - UpstreamStatusCode: 403, + UpstreamStatusCode: semanticStatus, UpstreamRequestID: resp.Header.Get("x-request-id"), Kind: "stream_error", Message: upstreamMsg, @@ -840,7 +860,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ) return nil, &UpstreamFailoverError{ - StatusCode: 403, + StatusCode: semanticStatus, ResponseBody: body, } } @@ -868,6 +888,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A UpstreamModel: mappedModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: reqStream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -875,6 +896,51 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }, nil } +func anthropicSpeedModel(parsed *ParsedRequest, result *ForwardResult) string { + if result != nil { + if upstreamModel := strings.TrimSpace(result.UpstreamModel); upstreamModel != "" { + return upstreamModel + } + } + if parsed == nil { + return "" + } + return parsed.Model +} + +// anthropicSpeedServiceTier 把 Anthropic 的 speed=fast 归一成可计费的 "fast" tier。 +// +// Fast mode 目前只在 Claude Opus 5 / Opus 4.8 上存在,且不支持 Bedrock 等第三方 +// 承载(Opus 4.7 的 fast mode 已被移除,传 speed=fast 会直接报错)。这里按模型和 +// 平台收紧,避免上游根本没跑 fast 时仍然按 2x 计费——宁可漏收也不能多收。 +// +// 这里只决定请求侧的档位;上游响应里的 usage.speed 由 UpstreamResponseServiceTier +// 带回,用量记录时经 ResolveBillingServiceTier 只降不升(usage.speed=standard 则按 +// 标准价计费)。 +func anthropicSpeedServiceTier(account *Account, speed, model string) *string { + if account == nil || account.Platform != PlatformAnthropic || speed != "fast" { + return nil + } + if account.IsBedrock() || !modelSupportsAnthropicFastMode(model) { + return nil + } + tier := "fast" + return &tier +} + +// modelSupportsAnthropicFastMode 判断模型是否属于支持 fast mode 的 Opus 5 / Opus 4.8。 +func modelSupportsAnthropicFastMode(model string) bool { + modelLower := strings.ToLower(strings.TrimSpace(model)) + if !strings.Contains(modelLower, "opus") { + return false + } + // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 + if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { + return true + } + return strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8") +} + // ResolveChannelMapping 委托渠道服务解析模型映射 func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { if s.channelService == nil { diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 93da85a7b2..7a7ad51f2b 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -165,12 +165,14 @@ func (s *GatewayService) ForwardAsChatCompletions( Kind: "failover", Message: upstreamMsg, }) + shouldDisable := false if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, mappedModel) + shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, mappedModel) } return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } @@ -179,7 +181,7 @@ func (s *GatewayService) ForwardAsChatCompletions( } // 13. Extract reasoning effort from CC request body - reasoningEffort := extractCCReasoningEffortFromBody(body) + reasoningEffort := extractCCReasoningEffortFromBody(body, mappedModel, originalModel) // 国产模型默认 effort 补充:本路径是客户端 CC 请求 → Anthropic 上游, // 如果上游是 passback-required 国产模型 (Kimi-anthropic / GLM-anthropic / MiniMax) // 且客户端在 body 里传了 thinking.type=enabled,补中默认 effort。 @@ -201,7 +203,7 @@ func (s *GatewayService) ForwardAsChatCompletions( // extractCCReasoningEffortFromBody reads reasoning effort from a Chat Completions // request body. It checks both nested (reasoning.effort) and flat (reasoning_effort) // formats used by OpenAI-compatible clients. -func extractCCReasoningEffortFromBody(body []byte) *string { +func extractCCReasoningEffortFromBody(body []byte, modelCandidates ...string) *string { raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) if raw == "" { raw = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) @@ -209,7 +211,11 @@ func extractCCReasoningEffortFromBody(body []byte) *string { if raw == "" { return nil } - normalized := normalizeOpenAIReasoningEffort(raw) + model := firstNonEmpty(modelCandidates...) + if model == "" { + model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) + } + normalized := normalizeOpenAIReasoningEffortForModel(raw, model) if normalized == "" { return nil } diff --git a/backend/internal/service/gateway_forward_as_chat_completions_test.go b/backend/internal/service/gateway_forward_as_chat_completions_test.go index d1047bdc9b..a5a5cbf913 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions_test.go +++ b/backend/internal/service/gateway_forward_as_chat_completions_test.go @@ -30,7 +30,23 @@ func TestExtractCCReasoningEffortFromBody(t *testing.T) { }) t.Run("DeepSeek max", func(t *testing.T) { - got := extractCCReasoningEffortFromBody([]byte(`{"reasoning_effort":"Max"}`)) + got := extractCCReasoningEffortFromBody([]byte(`{"model":"deepseek-v4-flash","reasoning_effort":"Max"}`)) + require.NotNil(t, got) + require.Equal(t, "max", *got) + }) + + t.Run("mapped Kimi alias max", func(t *testing.T) { + got := extractCCReasoningEffortFromBody( + []byte(`{"model":"public-alias","reasoning_effort":"max"}`), + "kimi-k3", + "public-alias", + ) + require.NotNil(t, got) + require.Equal(t, "max", *got) + }) + + t.Run("legacy model max", func(t *testing.T) { + got := extractCCReasoningEffortFromBody([]byte(`{"model":"gpt-5.5","reasoning_effort":"max"}`)) require.NotNil(t, got) require.Equal(t, "xhigh", *got) }) diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 2aa5255c29..6fcb1b2ed0 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -37,6 +37,14 @@ func (s *GatewayService) ForwardAsResponses( ) (*ForwardResult, error) { startTime := time.Now() + normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body) + if err != nil { + return nil, err + } + if normalized { + body = normalizedBody + } + // 1. Lower Codex client-side tools to function tools understood by Anthropic. adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body) if err != nil { @@ -63,7 +71,6 @@ func (s *GatewayService) ForwardAsResponses( // 4. Model mapping mappedModel := originalModel - reasoningEffort := ExtractResponsesReasoningEffortFromBody(body) if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) } @@ -78,6 +85,7 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } + reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, mappedModel, originalModel) // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 mapping 完成之后。 reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel) anthropicReq.Model = mappedModel @@ -170,12 +178,14 @@ func (s *GatewayService) ForwardAsResponses( Kind: "failover", Message: upstreamMsg, }) + shouldDisable := false if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, mappedModel) + shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, mappedModel) } return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, + StatusCode: resp.StatusCode, + ResponseBody: respBody, + RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } @@ -255,12 +265,16 @@ func liftResponsesAdditionalTools(requestBody map[string]any) (bool, error) { // ExtractResponsesReasoningEffortFromBody reads Responses API reasoning.effort // and normalizes it for usage logging. -func ExtractResponsesReasoningEffortFromBody(body []byte) *string { +func ExtractResponsesReasoningEffortFromBody(body []byte, modelCandidates ...string) *string { raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) if raw == "" { return nil } - normalized := normalizeOpenAIReasoningEffort(raw) + model := firstNonEmpty(modelCandidates...) + if model == "" { + model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) + } + normalized := normalizeOpenAIReasoningEffortForModel(raw, model) if normalized == "" { return nil } @@ -271,18 +285,46 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { if dst == nil { return } - if src.InputTokens > 0 { - dst.InputTokens = src.InputTokens + + // Some Anthropic-compatible providers retain OpenAI-style prompt/cache + // fields. Prefer those authoritative totals or hit/miss buckets over the + // overloaded input_tokens field. This covers Kimi's changing stream + // semantics as well as GLM/DeepSeek cache aliases. + if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil { + cacheReadTokens := src.CacheReadInputTokens + if cacheReadTokens == 0 && src.CachedTokens > 0 { + cacheReadTokens = src.CachedTokens + } + if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { + cacheReadTokens = src.PromptTokensDetails.CachedTokens + } + if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { + cacheReadTokens = max(*src.PromptCacheHitTokens, 0) + } + + if src.PromptCacheMissTokens != nil { + dst.InputTokens = max(*src.PromptCacheMissTokens, 0) + } else { + dst.InputTokens = max(src.PromptTokens-cacheReadTokens-src.CacheCreationInputTokens, 0) + } + dst.CacheReadInputTokens = cacheReadTokens + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } else { + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.CacheReadInputTokens > 0 { + dst.CacheReadInputTokens = src.CacheReadInputTokens + } else if src.CachedTokens > 0 { + dst.CacheReadInputTokens = src.CachedTokens + } + if src.CacheCreationInputTokens > 0 { + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } } if src.OutputTokens > 0 { dst.OutputTokens = src.OutputTokens } - if src.CacheReadInputTokens > 0 { - dst.CacheReadInputTokens = src.CacheReadInputTokens - } - if src.CacheCreationInputTokens > 0 { - dst.CacheCreationInputTokens = src.CacheCreationInputTokens - } } // parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value". diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 6784e7c7a5..262d588629 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -143,7 +143,19 @@ func TestExtractResponsesReasoningEffortFromBody(t *testing.T) { maxGot := ExtractResponsesReasoningEffortFromBody([]byte(`{"model":"deepseek-v4-pro","reasoning":{"effort":"max"}}`)) require.NotNil(t, maxGot) - require.Equal(t, "xhigh", *maxGot) + require.Equal(t, "max", *maxGot) + + mappedMax := ExtractResponsesReasoningEffortFromBody( + []byte(`{"model":"public-alias","reasoning":{"effort":"max"}}`), + "provider/glm-5.2", + "public-alias", + ) + require.NotNil(t, mappedMax) + require.Equal(t, "max", *mappedMax) + + legacyMax := ExtractResponsesReasoningEffortFromBody([]byte(`{"model":"gpt-5.5","reasoning":{"effort":"max"}}`)) + require.NotNil(t, legacyMax) + require.Equal(t, "xhigh", *legacyMax) require.Nil(t, ExtractResponsesReasoningEffortFromBody([]byte(`{"model":"claude-sonnet-4.5"}`))) } diff --git a/backend/internal/service/gateway_forward_partial_usage_test.go b/backend/internal/service/gateway_forward_partial_usage_test.go index f6701ca8b0..90f6757e01 100644 --- a/backend/internal/service/gateway_forward_partial_usage_test.go +++ b/backend/internal/service/gateway_forward_partial_usage_test.go @@ -8,12 +8,43 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type gatewayForwardErrorPolicyRepoStub struct { + AccountRepository + tempCalls int + overloadCalls int + modelRateLimitCalls []gatewayForwardModelRateLimitCall +} + +type gatewayForwardModelRateLimitCall struct { + accountID int64 + scope string +} + +func (r *gatewayForwardErrorPolicyRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error { + r.tempCalls++ + return nil +} + +func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context, id int64, scope string, _ time.Time, _ ...string) error { + r.modelRateLimitCalls = append(r.modelRateLimitCalls, gatewayForwardModelRateLimitCall{ + accountID: id, + scope: scope, + }) + return nil +} + +func (r *gatewayForwardErrorPolicyRepoStub) SetOverloaded(context.Context, int64, time.Time) error { + r.overloadCalls++ + return nil +} + // 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时, // 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同 // 返回,供 handler 照常提交 usage 记录。 @@ -183,6 +214,94 @@ func TestGatewayService_Forward_FailoverErrorKeepsNilResult(t *testing.T) { require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费") } +func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + const errorJSON = `{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_01"}` + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("event: error\ndata: " + errorJSON + "\n\n")), + }} + repo := &gatewayForwardErrorPolicyRepoStub{} + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil), + deferredService: &DeferredService{}, + } + account := newAnthropicOAuthAccountForPartialUsageTest() + account.Credentials["temp_unschedulable_enabled"] = true + account.Credentials["temp_unschedulable_rules"] = []any{map[string]any{ + "error_code": float64(529), + "keywords": []any{"Overloaded"}, + "duration_minutes": float64(10), + }} + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + require.Nil(t, result) + + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, 529, failoverErr.StatusCode) + require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody)) + require.Equal(t, 1, repo.overloadCalls, "synthetic 529 must apply global overload cooldown") + require.Empty(t, repo.modelRateLimitCalls, "global 529 cooldown must take precedence over custom model rules") + require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover") +} + +func TestGatewayService_Forward_PostOutputSSEOverloadedErrorKeepsExistingStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + const errorJSON = `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}` + fixture := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" + + "event: error\ndata: " + errorJSON + "\n\n" + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(fixture)), + }} + repo := &gatewayForwardErrorPolicyRepoStub{} + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil), + deferredService: &DeferredService{}, + } + + result, err := svc.Forward(context.Background(), c, newAnthropicOAuthAccountForPartialUsageTest(), parsed) + require.Error(t, err) + require.Nil(t, result) + + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusForbidden, failoverErr.StatusCode) + require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody)) + require.Zero(t, repo.tempCalls) + require.Contains(t, rec.Body.String(), "message_start") +} + func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 48cfc358a6..8855b13e52 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -158,6 +158,13 @@ func (s *stickyGatewayCacheHotpathStub) ReleaseGrokVideoBilled(_ context.Context return nil } +func (s *stickyGatewayCacheHotpathStub) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (s *stickyGatewayCacheHotpathStub) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { s.listByGroupCalls.Add(1) if s.err != nil { @@ -557,6 +564,46 @@ func TestGetAvailableModels_UsesShortCacheAndSupportsInvalidation(t *testing.T) require.Equal(t, int64(2), store) } +// Scenario: 账号模型变更会失效所属平台缓存 +func TestResolveCompositeModelOwnershipUsesModelsCacheInvalidation(t *testing.T) { + groupID := int64(9) + repo := &modelsListAccountRepoStub{ + byGroup: map[int64][]Account{ + groupID: {{ + ID: 1, + Platform: PlatformDeepseek, + Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "deepseek-v4-pro"}}, + }}, + }, + } + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + first, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, first) + require.Equal(t, int64(1), repo.listByGroupCalls.Load()) + + repo.byGroup[groupID] = []Account{{ + ID: 2, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "gpt-5"}}, + }} + cached, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, first, cached) + require.Equal(t, int64(1), repo.listByGroupCalls.Load()) + + svc.InvalidateAvailableModelsCache(&groupID, PlatformDeepseek) + refreshed, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, refreshed) + require.Equal(t, int64(2), repo.listByGroupCalls.Load()) +} + func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) { resetGatewayHotpathStatsForTest() @@ -602,6 +649,97 @@ func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) { require.Equal(t, int64(1), okRepo.listAllCalls.Load()) } +func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) { + groupID := int64(10) + + tests := []struct { + name string + accounts []Account + want []string + }{ + { + name: "passthrough only ignores stale mapping", + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "upstream-model"}}, + Extra: map[string]any{"openai_passthrough": true}, + }, + }, + want: nil, + }, + { + name: "passthrough wins over ordinary account mapping", + accounts: []Account{ + { + ID: 2, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"configured-model": "configured-upstream"}}, + }, + { + ID: 3, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "upstream-model"}}, + Extra: map[string]any{"openai_passthrough": true}, + }, + }, + want: nil, + }, + { + name: "ordinary accounts preserve mapped whitelist", + accounts: []Account{ + { + ID: 4, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"configured-model": "configured-upstream"}}, + }, + }, + want: []string{"configured-model"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &modelsListAccountRepoStub{byGroup: map[int64][]Account{groupID: tt.accounts}} + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + require.Equal(t, tt.want, svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI)) + }) + } +} + +func TestGetAvailableModels_GlobalListPreservesMappedModelsWithOpenAIPassthrough(t *testing.T) { + groupID := int64(11) + repo := &modelsListAccountRepoStub{ + byGroup: map[int64][]Account{ + groupID: { + { + ID: 1, + Platform: PlatformOpenAI, + Extra: map[string]any{"openai_passthrough": true}, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Credentials: map[string]any{"model_mapping": map[string]any{"claude-mapped": "claude-upstream"}}, + }, + }, + }, + } + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + require.Equal(t, []string{"claude-mapped"}, svc.GetAvailableModels(context.Background(), &groupID, "")) +} + func TestGatewayHotpathHelpers_CacheTTLAndStickyContext(t *testing.T) { t.Run("resolve_user_group_rate_cache_ttl", func(t *testing.T) { require.Equal(t, defaultUserGroupRateCacheTTL, resolveUserGroupRateCacheTTL(nil)) diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ac734a3642..a8834bc403 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -291,6 +291,13 @@ func (m *mockGatewayCacheForPlatform) ReleaseGrokVideoBilled(_ context.Context, return nil } +func (m *mockGatewayCacheForPlatform) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (m *mockGatewayCacheForPlatform) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + type mockGroupRepoForGateway struct { groups map[int64]*Group getByIDCalls int @@ -388,6 +395,61 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户") } +// Scenario: account-owned Composite aliases are scheduled only to accounts that declare the exact mapping. +func TestGatewayService_SelectAccountForModelWithExclusions_CompositeAliasRequiresOwningAccount(t *testing.T) { + groupID := int64(77) + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Priority: 1, + Status: StatusActive, + Schedulable: true, + AccountGroups: []AccountGroup{{GroupID: groupID}}, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Priority: 2, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"}, + }, + AccountGroups: []AccountGroup{{GroupID: groupID}}, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + group := &Group{ID: groupID, Platform: PlatformComposite, Status: StatusActive, Hydrated: true} + svc := &GatewayService{ + accountRepo: repo, + groupRepo: &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}}, + cfg: testConfig(), + } + ctx := WithCompositeRouteDecision(context.Background(), CompositeRouteDecision{ + Matched: true, + Source: CompositeRouteSourceAccount, + GroupID: groupID, + PublicModel: "reasoning-alias", + TargetPlatform: PlatformAnthropic, + UpstreamModel: "reasoning-alias", + Endpoint: CompositeRouteEndpointResponses, + }) + + account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "reasoning-alias", nil) + require.NoError(t, err) + require.NotNil(t, account) + require.Equal(t, int64(2), account.ID) +} + // TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择 func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/gateway_pool_mode_retry_test.go b/backend/internal/service/gateway_pool_mode_retry_test.go new file mode 100644 index 0000000000..83455e5b95 --- /dev/null +++ b/backend/internal/service/gateway_pool_mode_retry_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGatewayCompatPoolMode429AllowsSameAccountRetry(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + path string + body []byte + call func(*GatewayService, context.Context, *gin.Context, *Account, []byte) (*ForwardResult, error) + }{ + { + name: "chat completions", + path: "/v1/chat/completions", + body: []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`), + call: func(svc *GatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + return svc.ForwardAsChatCompletions(ctx, c, account, body, nil) + }, + }, + { + name: "responses", + path: "/v1/responses", + body: []byte(`{"model":"claude-sonnet-4-5","input":"hello"}`), + call: func(svc *GatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + return svc.ForwardAsResponses(ctx, c, account, body, nil) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"X-Request-Id": []string{"pool-429"}}, + Body: io.NopCloser(http.NoBody), + }}} + svc := &GatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + tlsFPProfileService: &TLSFingerprintProfileService{}, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, tt.path, nil) + account := &Account{ + ID: 1, + Name: "pool-account", + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "pool_mode": true, + }, + } + + result, err := tt.call(svc, context.Background(), c, account, tt.body) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.True(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, 1, upstream.callCount) + require.Empty(t, recorder.Body.String()) + }) + } +} diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 517d4723cb..0be0477029 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -345,6 +345,38 @@ func TestGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputTokens(t * require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) } +func TestGatewayServiceRecordUsage_TimePricingUsesPricingAt(t *testing.T) { + groupID := int64(904) + requestStart := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC) // 上海 10:00 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.resolver = newOpenAITokenImageChannelPricingResolverWithTimeForTest(t, groupID, "gpt-5.1", &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}, + }) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_time_pricing_request_start", + Model: "gpt-5.1", + Usage: ClaudeUsage{InputTokens: 1000, OutputTokens: 500}, + }, + APIKey: &APIKey{ID: 804, GroupID: i64p(groupID), Group: &Group{ + ID: groupID, RateMultiplier: 0.8, SubscriptionType: SubscriptionTypeSubscription, + }}, + User: &User{ID: 604}, + Account: &Account{ID: 704}, + PricingAt: requestStart, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + baseCost := 1000*3e-6 + 500*15e-6 + require.InDelta(t, baseCost*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, baseCost*2*0.8, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) +} func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T) { for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity} { t.Run(platform, func(t *testing.T) { @@ -662,3 +694,74 @@ func TestGatewayServiceRecordUsage_ReasoningEffortNil(t *testing.T) { require.NotNil(t, usageRepo.lastLog) require.Nil(t, usageRepo.lastLog.ReasoningEffort) } + +// newGatewayRecordUsageServiceWithResolverForTest mirrors production wiring for +// token billing: a pricing resolver plus a grouped API key select the unified +// billing path, which is the only one that honours the service tier. +func newGatewayRecordUsageServiceWithResolverForTest(usageRepo UsageLogRepository) (*GatewayService, *APIKey) { + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(7) + return svc, &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1.0}} +} + +func TestGatewayServiceRecordUsage_FastSpeedDowngradedByUpstreamResponse(t *testing.T) { + usageRepo := &openAIRecordUsageBestEffortLogRepoStub{} + svc, apiKey := newGatewayRecordUsageServiceWithResolverForTest(usageRepo) + + tier := "fast" + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "fast_downgraded_test", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: "claude-opus-5", + Duration: time.Second, + ServiceTier: &tier, + UpstreamResponseServiceTier: "standard", + }, + APIKey: apiKey, + User: &User{ID: 1}, + Account: &Account{ID: 1, Platform: PlatformAnthropic}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "standard", *usageRepo.lastLog.ServiceTier) + + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + standardCost, err := svc.billingService.CalculateCost("claude-opus-5", tokens, 1.0) + require.NoError(t, err) + fastCost, err := svc.billingService.CalculateCostWithServiceTier("claude-opus-5", tokens, 1.0, "fast") + require.NoError(t, err) + require.Greater(t, fastCost.TotalCost, standardCost.TotalCost, "fast mode must carry a premium for the test to be meaningful") + require.InDelta(t, standardCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} + +func TestGatewayServiceRecordUsage_FastSpeedHonouredKeepsPremium(t *testing.T) { + usageRepo := &openAIRecordUsageBestEffortLogRepoStub{} + svc, apiKey := newGatewayRecordUsageServiceWithResolverForTest(usageRepo) + + tier := "fast" + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "fast_honoured_test", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: "claude-opus-5", + Duration: time.Second, + ServiceTier: &tier, + UpstreamResponseServiceTier: "fast", + }, + APIKey: apiKey, + User: &User{ID: 1}, + Account: &Account{ID: 1, Platform: PlatformAnthropic}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "fast", *usageRepo.lastLog.ServiceTier) + + fastCost, err := svc.billingService.CalculateCostWithServiceTier("claude-opus-5", UsageTokens{InputTokens: 100, OutputTokens: 50}, 1.0, "fast") + require.NoError(t, err) + require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 4ffd5ee8e9..111b807f2f 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -117,6 +117,7 @@ func clearGatewayRequestDerivedState(parsed *ParsedRequest) { parsed.HasSystem = false parsed.ThinkingEnabled = false parsed.OutputEffort = "" + parsed.Speed = "" parsed.MaxTokens = 0 parsed.systemRange = missingJSONRange() parsed.messagesRange = missingJSONRange() @@ -224,6 +225,9 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) + if protocol == domain.PlatformAnthropic { + parsed.Speed = strings.ToLower(strings.TrimSpace(gjson.Get(jsonStr, "speed").String())) + } maxTokensResult := gjson.Get(jsonStr, "max_tokens") if maxTokensResult.Exists() && maxTokensResult.Type == gjson.Number { @@ -282,6 +286,7 @@ type ParsedRequest struct { HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入) ThinkingEnabled bool // 是否开启 thinking(部分平台会影响最终模型名) OutputEffort string // output_config.effort(Claude API 的推理强度控制) + Speed string // Anthropic speed(当前可计费值为 "fast") MaxTokens int // max_tokens 值(用于探测请求拦截) SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变) diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index d4e010a5e8..6074c02991 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -42,6 +42,22 @@ func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) { require.True(t, parsed.ThinkingEnabled) } +func TestParseGatewayRequest_AnthropicFastSpeed(t *testing.T) { + parsed, err := ParseGatewayRequest( + NewRequestBodyRef([]byte(`{"model":"claude-opus-4-8","speed":" FAST "}`)), + domain.PlatformAnthropic, + ) + require.NoError(t, err) + require.Equal(t, "fast", parsed.Speed) + + nonAnthropic, err := ParseGatewayRequest( + NewRequestBodyRef([]byte(`{"model":"gpt-5.4","speed":"fast"}`)), + "responses", + ) + require.NoError(t, err) + require.Empty(t, nonAnthropic.Speed) +} + func TestParseGatewayRequest_MaxTokens(t *testing.T) { body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`) parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "") diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index be63f804f5..977a28a99d 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -2532,6 +2532,11 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string { // isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context) // 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持 func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool { + if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount { + if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) { + return false + } + } if account.Platform == PlatformAntigravity { if strings.TrimSpace(requestedModel) == "" { return true diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 0f3415fab5..8910cec117 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -71,8 +71,9 @@ const ( ) const ( - cacheTTLTarget5m = "5m" - cacheTTLTarget1h = "1h" + cacheTTLTarget5m = "5m" + cacheTTLTarget1h = "1h" + compositeModelOwnershipCachePrefix = "composite-owner|" ) // ForceCacheBillingContextKey 强制缓存计费上下文键 @@ -192,8 +193,8 @@ func openAIStreamEventIsTerminalWithType(data, eventType string) bool { } func openAIStreamEventTypeIsTerminal(eventType string) bool { - switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + switch strings.TrimSpace(eventType) { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error": return true default: return false @@ -452,6 +453,10 @@ var allowedHeaders = map[string]bool{ // cache implementation (e.g. redis.Nil), mirroring ErrRefreshTokenNotFound. var ErrStickySessionNotFound = errors.New("sticky session not found") +// ErrReasoningContentNotFound is returned by GatewayCache.GetReasoningContent +// when no cached reasoning content exists for the reasoning item ID. +var ErrReasoningContentNotFound = errors.New("reasoning content not found") + // GatewayCache 定义网关服务的缓存操作接口。 // 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。 // @@ -486,6 +491,16 @@ type GatewayCache interface { ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error) // ReleaseGrokVideoBilled clears a claim so a failed RecordUsage can retry billing. ReleaseGrokVideoBilled(ctx context.Context, key string) error + + // Reasoning content cache (Responses→Chat Completions 桥接)。 + // SetReasoningContent 按 reasoning item id 缓存 reasoning 全文,供后续请求 + // 在客户端不回传明文 summary 时回注 reasoning_content(DeepSeek thinking + // mode 要求回传,否则 400)。 + SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error + // GetReasoningContent 返回缓存的 reasoning 全文;未命中返回 + // ErrReasoningContentNotFound,使 service 层无需依赖具体缓存实现即可 + // 区分"未缓存"与真实读取失败。 + GetReasoningContent(ctx context.Context, itemID string) (string, error) } // derefGroupID safely dereferences *int64 to int64, returning 0 if nil @@ -514,6 +529,10 @@ func modelsListCacheKey(groupID *int64, platform string) string { return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform)) } +func compositeModelOwnershipCacheKey(groupID int64, model string) string { + return fmt.Sprintf("%s%d|%s", compositeModelOwnershipCachePrefix, groupID, strings.TrimSpace(model)) +} + func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) { return PrefetchedStickyGroupIDFromContext(ctx) } @@ -600,11 +619,19 @@ type ForwardResult struct { // response before any client-facing rewrite or protocol conversion. UpstreamResponseModel string UpstreamResponseModelConflict bool - Stream bool - Duration time.Duration - FirstTokenMs *int // 首字时间(流式请求) - ClientDisconnect bool // 客户端是否在流式传输过程中断开 - ReasoningEffort *string + // UpstreamResponseServiceTier is the tier the upstream reports having used + // (Anthropic usage.speed: "fast" / "standard"); "" when not declared. + UpstreamResponseServiceTier string + Stream bool + Duration time.Duration + FirstTokenMs *int // 首字时间(流式请求) + ClientDisconnect bool // 客户端是否在流式传输过程中断开 + ReasoningEffort *string + // ServiceTier records the tier requested by the client. OpenAI uses + // service_tier; Anthropic speed=fast is normalized to "fast". Usage recording + // lowers it to UpstreamResponseServiceTier when the upstream reports a + // cheaper tier (see ResolveBillingServiceTier). + ServiceTier *string // 图片生成计费字段(图片生成模型使用) ImageCount int // 生成的图片数量 @@ -654,12 +681,15 @@ type GatewayFailureReason string // source-compatible and preserves their legacy retry-next-account behavior. type UpstreamFailoverError struct { StatusCode int - ResponseBody []byte // 上游响应体,用于错误透传规则匹配 - ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息 - ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true - RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 - RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 - SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 + ResponseBody []byte // 上游响应体,用于错误透传规则匹配 + ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息 + ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true + RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 + SameAccountRetryDelay time.Duration // 同账号重试的最小间隔;零值使用 handler 默认值 + SameAccountRetryDeadline time.Time // 同账号重试截止时间;零值表示仅受 retryLimit 限制 + SameAccountRetryMax int // 可选的错误级同账号重试上限,低于 handler 默认预算时优先采用 + RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 + SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 Stage GatewayFailureStage Scope GatewayFailureScope Reason GatewayFailureReason @@ -833,6 +863,9 @@ func NewGatewayService( balanceNotifyService: balanceNotifyService, userPlatformQuotaRepo: userPlatformQuotaRepo, } + if compositeResolver != nil { + compositeResolver.SetModelOwnershipResolver(svc.resolveCompositeModelOwnership) + } svc.userGroupRateResolver = newUserGroupRateResolver( userGroupRateRepo, svc.userGroupRateCache, @@ -1379,6 +1412,17 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, hasAnyMapping := false for _, acc := range accounts { + // Passthrough routing accepts models independently of model_mapping. A stale + // mapping on any eligible passthrough account therefore cannot define the + // public whitelist; return nil so the handler uses its default model set. + if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() { + if s.modelsListCache != nil { + s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL) + modelsListCacheStoreTotal.Add(1) + } + return nil + } + mapping := acc.GetModelMapping() if len(mapping) > 0 { hasAnyMapping = true @@ -1411,6 +1455,59 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, return cloneStringSlice(models) } +func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, groupID int64, model string) (CompositeModelOwnership, error) { + model = strings.TrimSpace(model) + if s == nil || s.accountRepo == nil || groupID <= 0 || model == "" { + return CompositeModelOwnership{}, nil + } + + cacheKey := compositeModelOwnershipCacheKey(groupID, model) + if s.modelsListCache != nil { + if cached, found := s.modelsListCache.Get(cacheKey); found { + if ownership, ok := cached.(CompositeModelOwnership); ok { + return ownership, nil + } + } + } + + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return CompositeModelOwnership{}, err + } + + platforms := make(map[string]struct{}) + for _, account := range accounts { + platform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) { + continue + } + platforms[platform] = struct{}{} + } + + ownership := CompositeModelOwnership{} + if len(platforms) == 1 { + for platform := range platforms { + ownership.TargetPlatform = platform + } + ownership.Matched = true + } else if len(platforms) > 1 { + ownership.Ambiguous = true + } + + if s.modelsListCache != nil { + s.modelsListCache.Set(cacheKey, ownership, s.modelsListCacheTTL) + } + return ownership, nil +} + +func explicitModelMappingClaims(account Account, model string) bool { + if account.Credentials == nil || model == "" { + return false + } + mapped, ok := stringMappingFromRaw(account.Credentials["model_mapping"])[model] + return ok && strings.TrimSpace(mapped) != "" +} + // GetSchedulablePlatforms returns the concrete platforms that currently have // schedulable accounts in the target group. func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} { @@ -1443,6 +1540,7 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform if s == nil || s.modelsListCache == nil { return } + s.invalidateCompositeModelOwnershipCache(groupID) normalizedPlatform := strings.TrimSpace(platform) // 完整匹配时精准失效;否则按维度批量失效。 @@ -1471,6 +1569,26 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform } } +func (s *GatewayService) invalidateCompositeModelOwnershipCache(groupID *int64) { + for key := range s.modelsListCache.Items() { + if !strings.HasPrefix(key, compositeModelOwnershipCachePrefix) { + continue + } + if groupID == nil { + s.modelsListCache.Delete(key) + continue + } + parts := strings.SplitN(strings.TrimPrefix(key, compositeModelOwnershipCachePrefix), "|", 2) + if len(parts) != 2 { + continue + } + cachedGroupID, err := strconv.ParseInt(parts[0], 10, 64) + if err == nil && cachedGroupID == *groupID { + s.modelsListCache.Delete(key) + } + } +} + const debugGatewayBodyDefaultFilename = "gateway_debug.log" // initDebugGatewayBodyFile 初始化网关调试日志文件。 @@ -1484,19 +1602,19 @@ func (s *GatewayService) initDebugGatewayBodyFile(path string) { } // 如果 path 指向一个已存在的目录,自动追加默认文件名 - if info, err := os.Stat(path); err == nil && info.IsDir() { + if info, err := os.Stat(path); err == nil && info.IsDir() { //nolint:gosec // G703: path 仅来自启动环境变量 SUB2API_DEBUG_GATEWAY_BODY(运维配置),非请求输入 path = filepath.Join(path, debugGatewayBodyDefaultFilename) } // 确保父目录存在 if dir := filepath.Dir(path); dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0755); err != nil { //nolint:gosec // G703: 同上 slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err) return } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) //nolint:gosec // G703: 同上 if err != nil { slog.Error("failed to open gateway debug log file", "path", path, "error", err) return diff --git a/backend/internal/service/gateway_streaming_test.go b/backend/internal/service/gateway_streaming_test.go index df8e8e4f80..cf0adfaa24 100644 --- a/backend/internal/service/gateway_streaming_test.go +++ b/backend/internal/service/gateway_streaming_test.go @@ -82,20 +82,19 @@ func TestParseSSEUsage_DeltaOverwritesWithNonZero(t *testing.T) { require.Equal(t, 60, usage.CacheReadInputTokens) } -func TestParseSSEUsage_DeltaDoesNotResetCacheCreationBreakdown(t *testing.T) { +func TestParseSSEUsage_DeltaAuthoritativelyUpdatesCacheCreationBreakdown(t *testing.T) { svc := newMinimalGatewayService() usage := &ClaudeUsage{} - // 先在 message_start 中写入非零 5m/1h 明细 - svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`, usage) - require.Equal(t, 30, usage.CacheCreation5mTokens) - require.Equal(t, 70, usage.CacheCreation1hTokens) + svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`, usage) + require.Equal(t, 463184, usage.CacheCreationInputTokens) + require.Equal(t, 0, usage.CacheCreation5mTokens) + require.Equal(t, 463184, usage.CacheCreation1hTokens) - // 后续 delta 带默认 0,不应覆盖已有非零值 - svc.parseSSEUsage(`{"type":"message_delta","usage":{"output_tokens":12,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0}}}`, usage) - require.Equal(t, 30, usage.CacheCreation5mTokens, "delta 的 0 值不应重置 5m 明细") - require.Equal(t, 70, usage.CacheCreation1hTokens, "delta 的 0 值不应重置 1h 明细") - require.Equal(t, 12, usage.OutputTokens) + svc.parseSSEUsage(`{"type":"message_delta","usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`, usage) + require.Equal(t, 463184, usage.CacheCreationInputTokens) + require.Equal(t, 463184, usage.CacheCreation5mTokens) + require.Equal(t, 0, usage.CacheCreation1hTokens) } func TestParseSSEUsage_InvalidJSON(t *testing.T) { diff --git a/backend/internal/service/gateway_tool_rewrite.go b/backend/internal/service/gateway_tool_rewrite.go index da62daf961..d1b6993723 100644 --- a/backend/internal/service/gateway_tool_rewrite.go +++ b/backend/internal/service/gateway_tool_rewrite.go @@ -248,14 +248,18 @@ func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte { return body } -// applyToolsLastCacheBreakpoint 在 tools 数组最后一个工具上注入 cache_control -// 断点,对齐 Parrot `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}` -// 行为,但 ttl 按本仓规则: +// applyToolsLastCacheBreakpoint 在最后一个非延迟加载工具上注入 cache_control +// 断点。Anthropic 不允许 defer_loading=true 的工具携带 cache_control, +// 因此会先清理所有延迟加载工具上的客户端断点。兼容官方顶层字段和 +// Claude Code 使用的 custom.defer_loading 字段。其余行为对齐 Parrot +// `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}`, +// 但 ttl 按本仓规则: // - 客户端已为该 tool 显式设置 cache_control.ttl → 完全透传不覆盖 // - 否则注入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL} // // 纯副作用函数,tools 不存在或为空数组时 no-op。 func applyToolsLastCacheBreakpoint(body []byte) []byte { + body = stripDeferredToolCacheControl(body) tools := gjson.GetBytes(body, "tools") if !tools.IsArray() { return body @@ -264,7 +268,17 @@ func applyToolsLastCacheBreakpoint(body []byte) []byte { if len(arr) == 0 { return body } - lastIdx := len(arr) - 1 + lastIdx := -1 + for idx, tool := range arr { + if isDeferredLoadingTool(tool) { + continue + } + lastIdx = idx + } + if lastIdx == -1 { + return body + } + existingCC := arr[lastIdx].Get("cache_control") if existingCC.Exists() && existingCC.Get("ttl").String() != "" { @@ -285,6 +299,29 @@ func applyToolsLastCacheBreakpoint(body []byte) []byte { return body } +func isDeferredLoadingTool(tool gjson.Result) bool { + return tool.Get("defer_loading").Type == gjson.True || + tool.Get("custom.defer_loading").Type == gjson.True +} + +// stripDeferredToolCacheControl removes the cache marker Anthropic rejects on +// deferred tools. Only the literal JSON boolean true enables deferred loading. +func stripDeferredToolCacheControl(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body + } + for idx, tool := range tools.Array() { + if !isDeferredLoadingTool(tool) || !tool.Get("cache_control").Exists() { + continue + } + if next, err := sjson.DeleteBytes(body, fmt.Sprintf("tools.%d.cache_control", idx)); err == nil { + body = next + } + } + return body +} + // restoreToolNamesInBytes 对 bytes chunk 做逆向还原:假名 → 真名。 // 按 ReverseOrdered 的假名长度倒序逐个 bytes.Replace,防止子串冲突 // (与 Parrot _restore_tool_names_in_chunk 的 sorted(..., reverse=True) 等价)。 diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go index 9e6f6806da..c894ef708c 100644 --- a/backend/internal/service/gateway_tool_rewrite_test.go +++ b/backend/internal/service/gateway_tool_rewrite_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "strings" "testing" @@ -149,6 +150,34 @@ func TestApplyToolsLastCacheBreakpoint_PassesThroughClientTTL(t *testing.T) { require.Equal(t, "1h", gjson.GetBytes(out, "tools.0.cache_control.ttl").String()) } +func TestApplyToolsLastCacheBreakpoint_StripsDeferredToolCacheControl(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral","ttl":"1h"}},{"name":"b","custom":{"defer_loading":true}}]}`) + out := applyToolsLastCacheBreakpoint(body) + + require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) +} + +func TestApplyToolsLastCacheBreakpoint_SkipsDeferredFinalTool(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","defer_loading":true}]}`) + out := applyToolsLastCacheBreakpoint(body) + + require.Equal(t, "ephemeral", gjson.GetBytes(out, "tools.0.cache_control.type").String()) + require.Equal(t, "5m", gjson.GetBytes(out, "tools.0.cache_control.ttl").String()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) +} + +func TestApplyToolsLastCacheBreakpoint_OnlyLiteralTrueIsDeferred(t *testing.T) { + body := []byte(`{"tools":[{"name":"custom-true","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"top-level-true","defer_loading":true,"cache_control":{"type":"ephemeral"}},{"name":"false","defer_loading":false,"cache_control":{"type":"ephemeral"}},{"name":"string","defer_loading":"true","cache_control":{"type":"ephemeral"}},{"name":"number","defer_loading":1,"cache_control":{"type":"ephemeral"}},{"name":"object","defer_loading":{},"cache_control":{"type":"ephemeral"}}]}`) + out := stripDeferredToolCacheControl(body) + + require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) + for idx := 2; idx < 6; idx++ { + require.Equal(t, "ephemeral", gjson.GetBytes(out, fmt.Sprintf("tools.%d.cache_control.type", idx)).String()) + } +} + func TestStripMessageCacheControl(t *testing.T) { body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}`) out := stripMessageCacheControl(body) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index c5d962f20b..0a8efbdd5e 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -19,6 +19,7 @@ import ( ) func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) { + body = stripDeferredToolCacheControl(body) if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream) return req, body, err diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 5a9fedf964..5663333a91 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -680,6 +680,7 @@ func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: true, Duration: time.Since(startTime), FirstTokenMs: streamResult.firstTokenMs, @@ -1236,11 +1237,11 @@ func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePat patch.hasCacheReadInput = true } if cc, ok := usageObj["cache_creation"].(map[string]any); ok { - if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists && v > 0 { + if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists { patch.cacheCreation5mTokens = v patch.hasCacheCreation5m = true } - if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists && v > 0 { + if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists { patch.cacheCreation1hTokens = v patch.hasCacheCreation1h = true } @@ -1395,7 +1396,7 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h } if err := json.Unmarshal(body, &response); err != nil { if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - return nil, s.invalidNonStreamingJSONFailoverError(ctx, resp, account, body, err, mappedModel) + return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err, mappedModel) } return nil, fmt.Errorf("parse response: %w", err) } diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index d28a1be19a..1832dd6d32 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -776,6 +776,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage account := input.Account subscription := input.Subscription ApplyForwardImageBillingResolution(result) + logServiceTierBillingDowngrade("service.gateway", account, result.RequestID, ApplyForwardServiceTierBillingResolution(result)) // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens // 用于粘性会话切换时的特殊计费处理 @@ -838,7 +839,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } // 计算费用 - cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) + cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts) // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 @@ -850,7 +851,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts) + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts) baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, @@ -939,12 +940,13 @@ func (s *GatewayService) calculateRecordUsageCost( billingModel string, multiplier float64, imageMultiplier float64, + pricingAt time.Time, opts *recordUsageOpts, ) *CostBreakdown { // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费 if result.ImageCount > 0 { if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken { - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) } return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } @@ -968,7 +970,7 @@ func (s *GatewayService) calculateRecordUsageCost( } // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。 - tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) if result.SearchCount > 0 { price := groupSearchPricePer1kFromAPIKey(apiKey) if price != nil && *price == 0 { @@ -1120,13 +1122,15 @@ func (s *GatewayService) calculateImageCost( return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } -// calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。 +// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价) +// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。 func (s *GatewayService) calculateTokenCost( ctx context.Context, result *ForwardResult, apiKey *APIKey, billingModel string, multiplier float64, + pricingAt time.Time, opts *recordUsageOpts, ) *CostBreakdown { tokens := UsageTokens{ @@ -1139,36 +1143,28 @@ func (s *GatewayService) calculateTokenCost( ImageOutputTokens: result.Usage.ImageOutputTokens, } - var cost *CostBreakdown - var err error - - // Explicit group/channel pricing wins. Built-in pricing also uses the unified - // resolver so the group long-context toggle can veto model-native tiers. - if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { + var resolved *ResolvedPricing + if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID - cost, err = s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - Tokens: tokens, - RequestCount: 1, - RateMultiplier: multiplier, - Resolver: s.resolver, - Resolved: resolved, - }) - } else if opts.LongContextThreshold > 0 && (apiKey.Group == nil || apiKey.Group.LongContextPricingEnabled) { - // 长上下文双倍计费(如 Gemini 200K 阈值) - cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) - } else if s.resolver != nil && apiKey.Group != nil { - gid := apiKey.Group.ID - cost, err = s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, Resolver: s.resolver, - }) - } else { - cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) + resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group}) } + var legacy *LegacyLongContextRule + if opts.LongContextThreshold > 0 { + legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier} + } + + cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + Resolver: s.resolver, + Resolved: resolved, + LegacyLongContext: legacy, + }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) return &CostBreakdown{ActualCost: 0} @@ -1176,6 +1172,14 @@ func (s *GatewayService) calculateTokenCost( return cost } +// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。 +func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule { + if s == nil || s.billingService == nil { + return nil + } + return s.billingService.LegacyLongContextRule(platform) +} + // buildRecordUsageLog 构建使用日志并设置计费模式。 func (s *GatewayService) buildRecordUsageLog( ctx context.Context, @@ -1216,6 +1220,7 @@ func (s *GatewayService) buildRecordUsageLog( UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), + ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index e76fbcee15..a56a1b3ebc 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -204,7 +204,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( c.Header("x-request-id", requestID) } - reasoningEffort := extractCCReasoningEffortFromBody(originalChatBody) + reasoningEffort := extractCCReasoningEffortFromBody(originalChatBody, mappedModel) // 国产模型默认 effort 补充(本路径上游是 Gemini,不会命中 passback-required)。 // 保持与 OpenAI 网关路径调用模式一致,便于未来上游变异时语义一致。 reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, originalChatBody, mappedModel) @@ -240,6 +240,11 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( } } + if policy == ErrorPolicySkipped && account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, requestID, evBody, func() { + _ = s.writeChatCompletionsError(c, http.StatusInternalServerError, "api_error", geminiCustomCodeSkippedClientMessage) + }) + } return nil, s.writeGeminiChatCompletionsMappedError(c, account, resp.StatusCode, requestID, evBody) } @@ -856,8 +861,13 @@ func (s *GeminiMessagesCompatService) writeGeminiChatCompletionsMappedError( if errType == "upstream_error" { errType = "invalid_request_error" } + // 400 是确定性的请求错误:回传上游 message(已脱敏),客户端据此定位非法字段。 if errMsg == "Upstream request failed" { - errMsg = "Invalid request" + if upstreamMsg != "" { + errMsg = upstreamMsg + } else { + errMsg = "Invalid request" + } } case http.StatusNotFound: statusCode = http.StatusNotFound diff --git a/backend/internal/service/gemini_error_policy_skipped_write_test.go b/backend/internal/service/gemini_error_policy_skipped_write_test.go new file mode 100644 index 0000000000..41c37e24da --- /dev/null +++ b/backend/internal/service/gemini_error_policy_skipped_write_test.go @@ -0,0 +1,213 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ErrorPolicySkipped 的客户端写出契约(与 OpenAI 网关路径对齐): +// - 池模式:不可 failover 的 4xx 按上游原始状态码/响应体保真写出,不改写成 5xx; +// - 自定义错误码未命中:统一 500 + 固定文案,上游细节只进 ops 错误日志; +// - 可 failover 的状态码(两种账号)一律换号,不透传。 +// --------------------------------------------------------------------------- + +const geminiSkippedTestUpstreamMsg = "antigravity executor: invalid Gemini function call history" + +func geminiSkippedTestUpstreamBody() string { + return `{"error":{"code":null,"message":"` + geminiSkippedTestUpstreamMsg + `","param":"","type":"invalid_request_error"}}` +} + +func newGeminiSkippedWriteService(status int, body string) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub) { + httpStub := &geminiCompatHTTPUpstreamStub{ + response: &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, + } + svc := &GeminiMessagesCompatService{ + httpUpstream: httpStub, + cfg: &config.Config{}, + rateLimitService: NewRateLimitService(&errorPolicyRepoStub{}, nil, &config.Config{}, nil, nil), + } + return svc, httpStub +} + +func geminiPoolModeAPIKeyAccount() *Account { + return &Account{ + ID: 700, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "pool_mode": true, + }, + } +} + +func geminiCustomCodesAPIKeyAccount() *Account { + return &Account{ + ID: 701, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + } +} + +func newGeminiNativeTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", strings.NewReader("{}")) + return c, rec +} + +func TestGeminiForwardNative_PoolModeSkipped400PassthroughRealStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + upstreamBody := geminiSkippedTestUpstreamBody() + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, upstreamBody) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "池模式 400 不应换号") + require.Contains(t, err.Error(), "gemini upstream error: 400") + require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400") + require.Equal(t, upstreamBody, rec.Body.String(), "响应体应原样透传") +} + +func TestGeminiForwardNative_PoolModeSkipped503Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusServiceUnavailable, `{"error":{"message":"Upstream service temporarily unavailable"}}`) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "池模式 503 应换号") + require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode) + require.Zero(t, rec.Body.Len(), "换号场景不应写客户端响应") +} + +func TestGeminiForwardNative_CustomCodesMiss400HiddenAs500(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "not in custom error codes") + require.Equal(t, http.StatusInternalServerError, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"]) + require.NotContains(t, rec.Body.String(), geminiSkippedTestUpstreamMsg, "上游细节不应透传给客户端") +} + +func TestGeminiForwardNative_CustomCodesMiss500Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusInternalServerError, `{"error":{"message":"internal"}}`) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "自定义错误码未命中的 500 应换号") + require.Equal(t, http.StatusInternalServerError, failoverErr.StatusCode) + require.False(t, failoverErr.RetryableOnSameAccount, "非池模式不应同账号重试") + require.Zero(t, rec.Body.Len()) +} + +func TestGeminiForwardAsChatCompletions_CustomCodesMiss400HiddenAs500(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCustomCodesAPIKeyAccount(), body) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "not in custom error codes") + require.Equal(t, http.StatusInternalServerError, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "api_error", errObj["type"]) + require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"]) +} + +func TestGeminiForwardAsChatCompletions_PoolMode400KeepsUpstreamMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiPoolModeAPIKeyAccount(), body) + + require.Nil(t, result) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400") + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "invalid_request_error", errObj["type"]) + require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message") +} + +func TestWriteGeminiMappedError_400KeepsUpstreamMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &GeminiMessagesCompatService{cfg: &config.Config{}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + err := svc.writeGeminiMappedError(c, &Account{ID: 702, Platform: PlatformGemini}, http.StatusBadRequest, "req-1", []byte(geminiSkippedTestUpstreamBody())) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message") +} diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index 46024077a5..807ed7056b 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -218,7 +218,7 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { expectHandleError: true, }, { - name: "custom_codes_skipped_500_no_failover", + name: "custom_codes_skipped_500_failover", account: &Account{ ID: 201, Type: AccountTypeAPIKey, @@ -230,6 +230,22 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { }, statusCode: 500, respBody: []byte(`{"error":"internal"}`), + expectFailover: true, + expectHandleError: false, + }, + { + name: "custom_codes_skipped_400_no_failover", + account: &Account{ + ID: 205, + Type: AccountTypeAPIKey, + Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + statusCode: 400, + respBody: []byte(`{"error":"bad request"}`), expectFailover: false, expectHandleError: false, }, @@ -311,9 +327,9 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { policy := svc.rateLimitService.CheckErrorPolicy(ctx, account, statusCode, respBody, "gemini-2.5-pro") switch policy { case ErrorPolicySkipped: - // Skipped → return error directly (no handleGeminiUpstreamError, no failover) - gotFailover = false + // Skipped → 不标记账号状态;可 failover 的状态码仍换号 handleErrorCalled = false + gotFailover = svc.skippedErrorPolicyFailoverError(c, account, statusCode, respBody, "req-test") != nil goto verify case ErrorPolicyMatched: svc.handleGeminiUpstreamError(ctx, account, statusCode, headers, respBody) @@ -353,12 +369,12 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { } // --------------------------------------------------------------------------- -// TestPoolModeSkippedFailoverError — pool-mode accounts hitting -// ErrorPolicySkipped must failover (align with other platform forwards) -// instead of passing the upstream error through to the client. +// TestSkippedErrorPolicyFailoverError — ErrorPolicySkipped(池模式、或自定义 +// 错误码未命中)不豁免换号:可 failover 的状态码返回 UpstreamFailoverError, +// 仅池模式账号可携带同账号重试标记。 // --------------------------------------------------------------------------- -func TestPoolModeSkippedFailoverError(t *testing.T) { +func TestSkippedErrorPolicyFailoverError(t *testing.T) { gin.SetMode(gin.TestMode) svc := &GeminiMessagesCompatService{} @@ -369,6 +385,13 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { } return &Account{ID: 300, Type: AccountTypeAPIKey, Platform: PlatformGemini, Credentials: creds} } + customCodesAccount := &Account{ + ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + } tests := []struct { name string @@ -383,13 +406,8 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { "pool_mode_retry_status_codes": []any{float64(500)}, }), 500, true, true}, {"pool_400_not_failover_worthy", poolAccount(nil), 400, false, false}, - {"non_pool_account_keeps_passthrough", &Account{ - ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, - Credentials: map[string]any{ - "custom_error_codes_enabled": true, - "custom_error_codes": []any{float64(429)}, - }, - }, 500, false, false}, + {"custom_codes_miss_500_failover_no_same_account_retry", customCodesAccount, 500, true, false}, + {"custom_codes_miss_400_not_failover_worthy", customCodesAccount, 400, false, false}, } for _, tt := range tests { @@ -399,7 +417,7 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) body := []byte(`{"error":{"code":"bad_response_status_code","message":"openai_error"}}`) - failoverErr := svc.poolModeSkippedFailoverError(c, tt.account, tt.statusCode, body, "req-1") + failoverErr := svc.skippedErrorPolicyFailoverError(c, tt.account, tt.statusCode, body, "req-1") if !tt.expectFailover { require.Nil(t, failoverErr) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 0abd14937a..5f9854167d 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -947,10 +947,16 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if upstreamReqID == "" { upstreamReqID = resp.Header.Get("x-goog-request-id") } - if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { + if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { return nil, failoverErr } - return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, upstreamReqID, respBody) + if account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, upstreamReqID, respBody, func() { + _ = s.writeClaudeError(c, http.StatusInternalServerError, "api_error", geminiCustomCodeSkippedClientMessage) + }) + } + // 池模式:客户端写出与 ErrorPolicyNone 相同(按上游真实状态码映射),仅跳过账号状态标记。 + return nil, s.writeGeminiMappedError(c, account, resp.StatusCode, upstreamReqID, respBody) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) @@ -1460,17 +1466,16 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) switch policy { case ErrorPolicySkipped: - if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { + if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { return nil, failoverErr } - respBody = unwrapIfNeeded(isOAuth, respBody) - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" + if account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, requestID, respBody, func() { + _ = s.writeGoogleError(c, http.StatusInternalServerError, geminiCustomCodeSkippedClientMessage) + }) } - MarkResponseCommitted(c) - c.Data(http.StatusInternalServerError, contentType, respBody) - return nil, fmt.Errorf("gemini upstream error: %d (skipped by error policy)", resp.StatusCode) + // 池模式:客户端写出与 ErrorPolicyNone 相同(状态码/响应体保真),仅跳过账号状态标记。 + return nil, s.writeGeminiNativeUpstreamError(c, account, resp, respBody, requestID, isOAuth) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) @@ -1555,40 +1560,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} } - respBody = unwrapIfNeeded(isOAuth, respBody) - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestID, - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, respBody) - if upstreamMsg == "" { - return nil, fmt.Errorf("gemini upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + return nil, s.writeGeminiNativeUpstreamError(c, account, resp, respBody, requestID, isOAuth) } var usage *ClaudeUsage @@ -1695,22 +1667,16 @@ func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCo } } -// poolModeSkippedFailoverError 池模式账号命中 ErrorPolicySkipped 时构造 failover 错误: -// 可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层按 pool_mode_retry_count -// 同账号重试后换号;返回 nil 表示不适用(非池模式或状态码不可 failover),由调用方透传。 -func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { - if !account.IsPoolMode() || !s.shouldFailoverGeminiUpstreamError(statusCode) { +// skippedErrorPolicyFailoverError 命中 ErrorPolicySkipped(池模式、或自定义错误码未命中) +// 时构造 failover 错误:可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层换号 +// (池模式账号按 pool_mode_retry_count 先同账号重试);返回 nil 表示状态码不可 failover, +// 由调用方决定客户端写出。Skipped 只豁免账号状态标记,不豁免换号,与 OpenAI 网关路径一致。 +func (s *GeminiMessagesCompatService) skippedErrorPolicyFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { + if !s.shouldFailoverGeminiUpstreamError(statusCode) { return nil } upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } + upstreamDetail := s.upstreamErrorDetail(respBody) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, AccountID: account.ID, @@ -1724,10 +1690,85 @@ func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Contex return &UpstreamFailoverError{ StatusCode: statusCode, ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolModeRetryableStatus(statusCode), + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode), } } +// geminiCustomCodeSkippedClientMessage 自定义错误码未命中时对客户端隐藏上游细节的固定文案, +// 与 OpenAI 网关路径同场景的文案一致。 +const geminiCustomCodeSkippedClientMessage = "Upstream gateway error" + +// upstreamErrorDetail 按配置截断上游错误响应体,用于 ops 错误日志的 Detail 字段; +// 未开启 LogUpstreamErrorBody 时返回空。 +func (s *GeminiMessagesCompatService) upstreamErrorDetail(body []byte) string { + if s.cfg == nil || !s.cfg.Gateway.LogUpstreamErrorBody { + return "" + } + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + return truncateString(string(body), maxBytes) +} + +// writeGeminiCustomCodeSkippedError 处理自定义错误码未命中且不可 failover 的上游错误: +// 客户端统一收到 500 + 固定文案(由 write 按端点格式写出),不透传上游细节; +// 上游真实状态码与错误信息仅记录到 ops 错误日志。 +func (s *GeminiMessagesCompatService) writeGeminiCustomCodeSkippedError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte, write func()) error { + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body))) + upstreamDetail := s.upstreamErrorDetail(body) + setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: upstreamStatus, + UpstreamRequestID: upstreamRequestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + write() + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d (not in custom error codes)", upstreamStatus) + } + return fmt.Errorf("gemini upstream error: %d (not in custom error codes) message=%s", upstreamStatus, upstreamMsg) +} + +// writeGeminiNativeUpstreamError 将不可 failover 的上游错误按原始状态码与响应体透传给客户端, +// 并记录 ops 错误事件。状态码保真:下游据此区分请求级错误与可重试的链路故障。 +func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Context, account *Account, resp *http.Response, respBody []byte, requestID string, isOAuth bool) error { + respBody = unwrapIfNeeded(isOAuth, respBody) + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := s.upstreamErrorDetail(respBody) + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + MarkResponseCommitted(c) + c.Data(resp.StatusCode, contentType, respBody) + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + func sleepGeminiBackoff(attempt int) { delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { @@ -1827,6 +1868,10 @@ func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, acc if errType == "" { errType = "invalid_request_error" } + // 400 是确定性的请求错误:回传上游 message(已脱敏),客户端据此定位非法字段。 + if errMsg == "" { + errMsg = upstreamMsg + } if errMsg == "" { errMsg = "Invalid request" } @@ -3542,12 +3587,20 @@ func cleanToolSchema(schema any) any { if key == "$schema" || key == "$id" || key == "$ref" || key == "$defs" || key == "definitions" || key == "additionalProperties" || key == "patternProperties" || key == "minLength" || - key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" { + key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" || + key == "deprecated" { continue } // 递归清理嵌套对象 cleaned[key] = cleanToolSchema(value) } + if enum, ok := cleaned["enum"].([]any); ok { + if normalized, ok := normalizeGeminiEnum(enum); ok { + cleaned["enum"] = normalized + } else { + delete(cleaned, "enum") + } + } // 规范化 type 字段为大写 if typeVal, ok := cleaned["type"].(string); ok { cleaned["type"] = strings.ToUpper(typeVal) @@ -3582,6 +3635,28 @@ func cleanToolSchema(schema any) any { } } +func normalizeGeminiEnum(values []any) ([]any, bool) { + normalized := make([]any, len(values)) + for i, value := range values { + if stringValue, ok := value.(string); ok { + normalized[i] = stringValue + continue + } + + switch value.(type) { + case nil, bool, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number: + encoded, err := json.Marshal(value) + if err != nil { + return nil, false + } + normalized[i] = string(encoded) + default: + return nil, false + } + } + return normalized, true +} + func incrementIntegralSchemaBound(value any) (any, bool) { switch v := value.(type) { case float64: diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index 2023edc979..e9ee74e3be 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -471,6 +471,44 @@ func TestCleanToolSchema_DropsAmbiguousExclusiveMinimumWithoutConversion(t *test } } +func TestCleanToolSchema_RemovesNestedDeprecatedAndNormalizesMixedScalarEnum(t *testing.T) { + schema := map[string]any{ + "anyOf": []any{ + map[string]any{ + "type": "string", + "deprecated": true, + }, + map[string]any{ + "enum": []any{"enabled", false, float64(1), nil}, + }, + }, + } + + cleaned, ok := cleanToolSchema(schema).(map[string]any) + require.True(t, ok) + anyOf, ok := cleaned["anyOf"].([]any) + require.True(t, ok) + require.Len(t, anyOf, 2) + + deprecatedSchema, ok := anyOf[0].(map[string]any) + require.True(t, ok) + require.NotContains(t, deprecatedSchema, "deprecated") + + enumSchema, ok := anyOf[1].(map[string]any) + require.True(t, ok) + require.Equal(t, []any{"enabled", "false", "1", "null"}, enumSchema["enum"]) +} + +func TestCleanToolSchema_DropsEnumWithNonScalarValue(t *testing.T) { + schema := map[string]any{ + "enum": []any{"valid", map[string]any{"invalid": true}}, + } + + cleaned, ok := cleanToolSchema(schema).(map[string]any) + require.True(t, ok) + require.NotContains(t, cleaned, "enum") +} + func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t *testing.T) { tools := []any{ map[string]any{ diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 63ebc929b9..9a89998d51 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -319,6 +319,13 @@ func (m *mockGatewayCacheForGemini) ReleaseGrokVideoBilled(_ context.Context, _ return nil } +func (m *mockGatewayCacheForGemini) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (m *mockGatewayCacheForGemini) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + // TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 06c8410b8d..0cd138cad5 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -16,6 +16,10 @@ import ( "github.com/tidwall/gjson" ) +// DefaultGrokRealtimeDialTimeout bounds the pre-accept upstream handshake. +// The timeout only covers dialing; an established session is not interrupted. +const DefaultGrokRealtimeDialTimeout = 12 * time.Second + // supportedGrokVoiceHTTPEndpoints are xAI Voice HTTP paths we forward as-is. var supportedGrokVoiceHTTPEndpoints = map[string]struct{}{ "tts": {}, @@ -125,35 +129,79 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con if account.Platform != PlatformGrok { return false, fmt.Errorf("account platform %s is not supported for grok realtime", account.Platform) } - base, err := buildGrokVoiceURL(account, s.cfg, "realtime") - if err != nil { - return false, err - } - u, err := url.Parse(base) - if err != nil { - return false, err - } - u.Scheme = "wss" - u.RawQuery = "model=" + url.QueryEscape(firstNonEmpty(model, "grok-voice-latest")) - headers := http.Header{"Authorization": []string{"Bearer " + token}} - // Match media/voice HTTP: CLI headers only on CLI proxy hosts. - if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) { - applyGrokCLIHeaders(headers) - } - if account != nil { - account.ApplyHeaderOverrides(headers) - } - - dialer := s.getOpenAIWSPassthroughDialer() - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - upstream, _, _, err := dialer.Dial(ctx, u.String(), headers, proxyURL) + upstream, err := s.OpenGrokRealtime(ctx, account, token, model) if err != nil { return false, err } defer func() { _ = upstream.Close() }() + return s.ProxyGrokRealtimeConn(ctx, c, client, upstream) +} + +type GrokRealtimeUpstream struct{ conn openAIWSClientConn } + +// GrokRealtimeDialError preserves an HTTP status returned before WebSocket +// upgrade so handlers can apply the normal Grok account policy. +type GrokRealtimeDialError struct { + StatusCode int + Err error +} + +func (e *GrokRealtimeDialError) Error() string { return e.Err.Error() } +func (e *GrokRealtimeDialError) Unwrap() error { return e.Err } + +func (u *GrokRealtimeUpstream) Close() error { + if u == nil || u.conn == nil { + return nil + } + return u.conn.Close() +} + +func (s *OpenAIGatewayService) OpenGrokRealtime(ctx context.Context, account *Account, token, model string) (*GrokRealtimeUpstream, error) { + if s == nil || account == nil || account.Platform != PlatformGrok { + return nil, fmt.Errorf("grok realtime account is required") + } + base, err := buildGrokVoiceURL(account, s.cfg, "realtime") + if err != nil { + return nil, err + } + u, err := url.Parse(base) + if err != nil { + return nil, err + } + u.Scheme = "wss" + q := u.Query() + q.Set("model", firstNonEmpty(model, "grok-voice-latest")) + u.RawQuery = q.Encode() + headers := http.Header{"Authorization": []string{"Bearer " + token}} + if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) { + applyGrokCLIHeaders(headers) + } + account.ApplyHeaderOverrides(headers) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + conn, status, _, err := s.getOpenAIWSPassthroughDialer().Dial(ctx, u.String(), headers, proxyURL) + if err != nil { + return nil, &GrokRealtimeDialError{StatusCode: status, Err: err} + } + return &GrokRealtimeUpstream{conn: conn}, nil +} + +// HandleGrokRealtimeUpstreamError applies the shared Grok account policy to a +// failed pre-accept WebSocket handshake. +func (s *OpenAIGatewayService) HandleGrokRealtimeUpstreamError(ctx context.Context, account *Account, statusCode int, body []byte) { + if statusCode <= 0 { + statusCode = http.StatusBadGateway + } + s.handleGrokAccountUpstreamError(ctx, account, statusCode, nil, body) +} + +func (s *OpenAIGatewayService) ProxyGrokRealtimeConn(ctx context.Context, c *gin.Context, client *coderws.Conn, upstream *GrokRealtimeUpstream) (bool, error) { + if s == nil || client == nil || upstream == nil || upstream.conn == nil { + return false, fmt.Errorf("realtime connection is required") + } + conn := upstream.conn ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -163,7 +211,7 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con // Upstream → client go func() { for { - msg, readErr := upstream.ReadMessage(ctx) + msg, readErr := conn.ReadMessage(ctx) if readErr != nil { errCh <- readErr return @@ -197,7 +245,7 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con errCh <- fmt.Errorf("invalid realtime event: %w", unmarshalErr) return } - if writeErr := upstream.WriteJSON(ctx, raw); writeErr != nil { + if writeErr := conn.WriteJSON(ctx, raw); writeErr != nil { errCh <- writeErr return } @@ -207,6 +255,45 @@ func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Con return awaitGrokRealtimeAudioObserved(errCh, &audioObserved) } +// ProbeGrokRealtime performs the upstream WebSocket handshake without sending +// any client-visible events. Handlers use it before accepting the downstream +// upgrade so authentication and endpoint failures remain ordinary HTTP errors. +func (s *OpenAIGatewayService) ProbeGrokRealtime(ctx context.Context, account *Account, token, model string) error { + if s == nil || account == nil { + return fmt.Errorf("realtime service and account are required") + } + if account.Platform != PlatformGrok { + return fmt.Errorf("account platform %s is not supported for grok realtime", account.Platform) + } + base, err := buildGrokVoiceURL(account, s.cfg, "realtime") + if err != nil { + return err + } + u, err := url.Parse(base) + if err != nil { + return err + } + u.Scheme = "wss" + q := u.Query() + q.Set("model", firstNonEmpty(model, "grok-voice-latest")) + u.RawQuery = q.Encode() + headers := http.Header{"Authorization": []string{"Bearer " + token}} + if account.IsGrokOAuth() && isGrokCLIProxyTarget(u.String()) { + applyGrokCLIHeaders(headers) + } + account.ApplyHeaderOverrides(headers) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + dialer := s.getOpenAIWSPassthroughDialer() + conn, _, _, err := dialer.Dial(ctx, u.String(), headers, proxyURL) + if err != nil { + return err + } + return conn.Close() +} + func awaitGrokRealtimeAudioObserved(errCh <-chan error, audioObserved *atomic.Bool) (bool, error) { err := <-errCh if audioObserved == nil { diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 2efb2332f6..85dd1b14ec 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -59,6 +59,8 @@ type GrokMediaRequestInfo struct { N int Size string SizeTier string + AspectRatio string + ImageResolution string Resolution string DurationSeconds int InputImageURLs []string @@ -127,6 +129,8 @@ func ParseGrokMediaRequest(contentType string, body []byte) GrokMediaRequestInfo info.Prompt = strings.TrimSpace(info.Prompt) info.Size = strings.TrimSpace(info.Size) info.SizeTier = NormalizeImageBillingTierOrDefault(info.Size) + info.AspectRatio = strings.TrimSpace(info.AspectRatio) + info.ImageResolution = grokImagineImageResolution(info.ImageResolution) info.Resolution = NormalizeVideoBillingResolutionOrDefault(info.Resolution) info.DurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(info.DurationSeconds) if info.N <= 0 { @@ -142,7 +146,8 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { info.Model = strings.TrimSpace(gjson.GetBytes(body, "model").String()) info.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) info.Size = strings.TrimSpace(gjson.GetBytes(body, "size").String()) - info.Resolution = strings.TrimSpace(gjson.GetBytes(body, "resolution").String()) + info.AspectRatio = strings.TrimSpace(gjson.GetBytes(body, "aspect_ratio").String()) + assignGrokMediaResolution(strings.TrimSpace(gjson.GetBytes(body, "resolution").String()), info) if duration := gjson.GetBytes(body, "duration"); duration.Exists() && duration.Type == gjson.Number { info.DurationSeconds = int(duration.Int()) } @@ -255,8 +260,10 @@ func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokM info.Prompt = value case "size": info.Size = value + case "aspect_ratio": + info.AspectRatio = value case "resolution": - info.Resolution = value + assignGrokMediaResolution(value, info) case "duration": if duration, err := strconv.Atoi(value); err == nil { info.DurationSeconds = duration @@ -934,6 +941,12 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten if info.Size != "" { payload["size"] = info.Size } + if info.ImageResolution != "" { + payload["resolution"] = info.ImageResolution + } + if info.AspectRatio != "" { + payload["aspect_ratio"] = info.AspectRatio + } images := make([]map[string]string, 0, len(info.InputImageURLs)+len(info.Uploads)) for _, imageURL := range info.InputImageURLs { @@ -1106,10 +1119,7 @@ func sanitizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conte } switch endpoint { case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits: - if !gjson.GetBytes(body, "size").Exists() { - return body, contentType, nil - } - out, err := sjson.DeleteBytes(body, "size") + out, err := applyGrokImagineImageGeometry(body) if err != nil { return nil, "", fmt.Errorf("sanitize grok media size: %w", err) } @@ -1288,11 +1298,16 @@ func (s *OpenAIGatewayService) handleGrokMediaErrorResponse( Detail: upstreamDetail, }) if kind == "failover" { + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, body) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: body, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } diff --git a/backend/internal/service/grok_media_image_geometry.go b/backend/internal/service/grok_media_image_geometry.go new file mode 100644 index 0000000000..51e2a6fd69 --- /dev/null +++ b/backend/internal/service/grok_media_image_geometry.go @@ -0,0 +1,149 @@ +package service + +import ( + "math" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Official Imagine image geometry: https://docs.x.ai/developers/model-capabilities/images/generation +var grokImagineAspectRatioValues = []struct { + label string + ratio float64 +}{ + {"1:1", 1}, + {"16:9", 16.0 / 9.0}, + {"9:16", 9.0 / 16.0}, + {"4:3", 4.0 / 3.0}, + {"3:4", 3.0 / 4.0}, + {"3:2", 1.5}, + {"2:3", 2.0 / 3.0}, + {"2:1", 2}, + {"1:2", 0.5}, + {"19.5:9", 19.5 / 9.0}, + {"9:19.5", 9.0 / 19.5}, + {"20:9", 20.0 / 9.0}, + {"9:20", 9.0 / 20.0}, +} + +func applyGrokImagineImageGeometry(body []byte) ([]byte, error) { + size := strings.TrimSpace(gjson.GetBytes(body, "size").String()) + resolution := grokImagineImageResolution(gjson.GetBytes(body, "resolution").String()) + aspect := strings.TrimSpace(gjson.GetBytes(body, "aspect_ratio").String()) + out := append([]byte(nil), body...) + + if resolution == "" { + if derived := grokImagineImageResolutionFromSize(size); derived != "" { + next, err := sjson.SetBytes(out, "resolution", derived) + if err != nil { + return nil, err + } + out = next + } + } else if gjson.GetBytes(body, "resolution").String() != resolution { + next, err := sjson.SetBytes(out, "resolution", resolution) + if err != nil { + return nil, err + } + out = next + } + + if aspect == "" { + if derived := grokImagineAspectRatioFromSize(size); derived != "" { + next, err := sjson.SetBytes(out, "aspect_ratio", derived) + if err != nil { + return nil, err + } + out = next + } + } + + if !gjson.GetBytes(out, "size").Exists() { + return out, nil + } + return sjson.DeleteBytes(out, "size") +} + +func assignGrokMediaResolution(value string, info *GrokMediaRequestInfo) { + if info == nil { + return + } + value = strings.TrimSpace(value) + if value == "" { + return + } + if img := grokImagineImageResolution(value); img != "" { + info.ImageResolution = img + return + } + info.Resolution = value +} + +func grokImagineImageResolution(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1k": + return "1k" + case "2k": + return "2k" + default: + return "" + } +} + +func grokImagineImageResolutionFromSize(size string) string { + if explicit := grokImagineImageResolution(size); explicit != "" { + return explicit + } + tier, ok := ClassifyImageBillingTier(size) + if !ok { + return "" + } + if tier == ImageBillingSize1K { + return "1k" + } + return "2k" +} + +func grokImagineAspectRatioFromSize(size string) string { + width, height, ok := parseImageBillingDimensions(strings.TrimSpace(size)) + if !ok || width <= 0 || height <= 0 { + return "" + } + div := grokImagineGCD(width, height) + exact := strconv.Itoa(width/div) + ":" + strconv.Itoa(height/div) + for _, candidate := range grokImagineAspectRatioValues { + if candidate.label == exact { + return exact + } + } + ratio := float64(width) / float64(height) + bestLabel := "" + bestDelta := math.MaxFloat64 + for _, candidate := range grokImagineAspectRatioValues { + delta := math.Abs(ratio - candidate.ratio) + if delta < bestDelta { + bestDelta = delta + bestLabel = candidate.label + } + } + return bestLabel +} + +func grokImagineGCD(a, b int) int { + if a < 0 { + a = -a + } + if b < 0 { + b = -b + } + for b != 0 { + a, b = b, a%b + } + if a == 0 { + return 1 + } + return a +} diff --git a/backend/internal/service/grok_media_image_geometry_test.go b/backend/internal/service/grok_media_image_geometry_test.go new file mode 100644 index 0000000000..cac26c3bd1 --- /dev/null +++ b/backend/internal/service/grok_media_image_geometry_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestApplyGrokImagineImageGeometryMapsOpenAISize(t *testing.T) { + t.Parallel() + + out, err := applyGrokImagineImageGeometry([]byte(`{"model":"grok-imagine-image-2.0","prompt":"hi","size":"1152x1536"}`)) + require.NoError(t, err) + require.False(t, gjson.GetBytes(out, "size").Exists()) + require.Equal(t, "2k", gjson.GetBytes(out, "resolution").String()) + require.Equal(t, "3:4", gjson.GetBytes(out, "aspect_ratio").String()) +} + +func TestApplyGrokImagineImageGeometryKeepsClientGeometry(t *testing.T) { + t.Parallel() + + out, err := applyGrokImagineImageGeometry([]byte(`{"size":"1024x1024","resolution":"2K","aspect_ratio":"16:9"}`)) + require.NoError(t, err) + require.False(t, gjson.GetBytes(out, "size").Exists()) + require.Equal(t, "2k", gjson.GetBytes(out, "resolution").String()) + require.Equal(t, "16:9", gjson.GetBytes(out, "aspect_ratio").String()) +} + +func TestSanitizeGrokMediaForwardBodyConvertsImageSize(t *testing.T) { + t.Parallel() + + out, contentType, err := sanitizeGrokMediaForwardBody( + GrokMediaEndpointImagesGenerations, + []byte(`{"model":"grok-imagine-image","prompt":"hi","size":"1024x1024"}`), + "application/json", + ) + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + require.False(t, gjson.GetBytes(out, "size").Exists()) + require.Equal(t, "1k", gjson.GetBytes(out, "resolution").String()) + require.Equal(t, "1:1", gjson.GetBytes(out, "aspect_ratio").String()) +} + +func TestParseGrokMediaRequestKeepsImageResolutionOutOfVideoNormalize(t *testing.T) { + t.Parallel() + + info := ParseGrokMediaRequest("application/json", []byte(`{"model":"grok-imagine-image-2.0","resolution":"2K","aspect_ratio":"16:9"}`)) + require.Equal(t, "2k", info.ImageResolution) + require.Equal(t, "16:9", info.AspectRatio) + require.Equal(t, VideoBillingResolution480P, info.Resolution) +} + +func TestGrokImagineAspectRatioFromSize(t *testing.T) { + t.Parallel() + require.Equal(t, "1:1", grokImagineAspectRatioFromSize("1024x1024")) + require.Equal(t, "3:4", grokImagineAspectRatioFromSize("1152x1536")) + require.Equal(t, "4:3", grokImagineAspectRatioFromSize("1536x1152")) + require.Equal(t, "16:9", grokImagineAspectRatioFromSize("1792x1024")) +} diff --git a/backend/internal/service/grok_observed_models.go b/backend/internal/service/grok_observed_models.go index 480ba457bd..3dba5f0013 100644 --- a/backend/internal/service/grok_observed_models.go +++ b/backend/internal/service/grok_observed_models.go @@ -92,7 +92,7 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account * } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", grokUpstreamUserAgent) + req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) if account.IsGrokOAuth() { applyGrokCLIHeaders(req.Header) if isGrokCLIProxyTarget(req.URL.String()) { diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 70ee2c8d72..af27b67f12 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -133,8 +133,9 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { require.NoError(t, svc.syncGrokObservedModels(context.Background(), account)) require.Equal(t, xai.DefaultCLIBaseURL+"/models", upstream.lastReq.URL.String()) - require.NotEmpty(t, upstream.lastReq.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, upstream.lastReq.Header.Get("x-grok-client-version")) require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) require.Equal(t, "user-902", upstream.lastReq.Header.Get("X-UserID")) require.Equal(t, "user902@example.test", upstream.lastReq.Header.Get("X-Email")) @@ -301,6 +302,22 @@ func (u *grokHybridUpstream) snapshot() ([]*http.Request, [][]byte) { return requests, bodies } +// quotaSnapshot 返回配额探测链路的请求及请求体,不含 scheduleGrokObservedModelsSync +// 在 QueryQuota 返回后异步发出的 GET /v1/models:该请求是否已落到上游取决于调度时序。 +func (u *grokHybridUpstream) quotaSnapshot() ([]*http.Request, [][]byte) { + requests, bodies := u.snapshot() + quotaRequests := make([]*http.Request, 0, len(requests)) + quotaBodies := make([][]byte, 0, len(bodies)) + for i, req := range requests { + if req.URL.Path == "/v1/models" { + continue + } + quotaRequests = append(quotaRequests, req) + quotaBodies = append(quotaBodies, bodies[i]) + } + return quotaRequests, quotaBodies +} + func (r *grokQuotaProxyRepo) GetByID(_ context.Context, id int64) (*Proxy, error) { r.calls++ return r.proxies[id], nil @@ -741,7 +758,7 @@ func TestGrokQuotaServiceQueryQuotaFreeFallsBackToGrok45(t *testing.T) { require.EqualValues(t, 2_000_000, *result.Snapshot.Tokens.Limit) require.True(t, result.HeadersObserved) - requests, bodies := upstream.snapshot() + requests, bodies := upstream.quotaSnapshot() require.Len(t, requests, 3) responseCalls := 0 for i, req := range requests { @@ -781,7 +798,7 @@ func TestGrokQuotaServiceQueryQuotaPaidBillingSkipsActiveProbe(t *testing.T) { require.Empty(t, result.Model) require.Nil(t, result.LocalUsage24h) - requests, _ := upstream.snapshot() + requests, _ := upstream.quotaSnapshot() require.Len(t, requests, 2) for _, req := range requests { require.Equal(t, "/v1/billing", req.URL.Path) @@ -806,7 +823,7 @@ func TestGrokQuotaServiceQueryQuotaCustomPaidMonthlyLimitSkipsActiveProbe(t *tes require.InDelta(t, monthlyLimit, *result.Billing.MonthlyLimitCents, 1e-9) require.Nil(t, result.Snapshot) - requests, _ := upstream.snapshot() + requests, _ := upstream.quotaSnapshot() require.Len(t, requests, 2) for _, req := range requests { require.Equal(t, "/v1/billing", req.URL.Path) diff --git a/backend/internal/service/grok_stream_idle.go b/backend/internal/service/grok_stream_idle.go index 3bdfa27fc9..ddfa9f80bd 100644 --- a/backend/internal/service/grok_stream_idle.go +++ b/backend/internal/service/grok_stream_idle.go @@ -32,7 +32,16 @@ func grokStreamIdleFailoverError(account *Account, idle time.Duration) *Upstream StatusCode: 502, ResponseBody: []byte(`{"error":{"code":"empty_upstream","message":"` + strings.ReplaceAll(msg, `"`, `'`) + `"}}`), SafeToFailoverAfterWrite: true, - // Allow pool-mode retries; normal OAuth switches account via handler. - RetryableOnSameAccount: account != nil && account.IsPoolMode(), + // An idle upstream stream is transient and should get the configured + // same-account retry budget before switching credentials. This applies + // to both pooled and dedicated Grok accounts; the handler still enforces + // the request's retry limit. + RetryableOnSameAccount: account != nil && account.Platform == PlatformGrok, + RequestScopedTransient: true, + SameAccountRetryMax: 1, + // Permit at most one same-account replay after the idle failure. The + // deadline is anchored at failure time, so a hung stream cannot consume + // the normal three-attempt budget before failover. + SameAccountRetryDeadline: time.Now().Add(idle), } } diff --git a/backend/internal/service/grok_stream_idle_test.go b/backend/internal/service/grok_stream_idle_test.go index e3c6d16943..7110dac9f2 100644 --- a/backend/internal/service/grok_stream_idle_test.go +++ b/backend/internal/service/grok_stream_idle_test.go @@ -21,5 +21,16 @@ func TestGrokStreamIdleFailoverError(t *testing.T) { require.NotNil(t, err) require.Equal(t, 502, err.StatusCode) require.True(t, err.SafeToFailoverAfterWrite) + require.True(t, err.RetryableOnSameAccount) + require.True(t, err.RequestScopedTransient) + require.Equal(t, 1, err.SameAccountRetryMax) require.Contains(t, string(err.ResponseBody), "empty_upstream") + require.WithinDuration(t, time.Now().Add(180*time.Second), err.SameAccountRetryDeadline, 2*time.Second) +} + +func TestGrokStreamIdleFailoverErrorRequiresGrokAccount(t *testing.T) { + openAI := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + err := grokStreamIdleFailoverError(openAI, time.Second) + require.False(t, err.RetryableOnSameAccount) + require.True(t, err.RequestScopedTransient) } diff --git a/backend/internal/service/grok_upstream_errors.go b/backend/internal/service/grok_upstream_errors.go index 0039ea4cdc..816ac2fb97 100644 --- a/backend/internal/service/grok_upstream_errors.go +++ b/backend/internal/service/grok_upstream_errors.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" "time" + + "github.com/tidwall/gjson" ) // isGrokContentPolicyRejection identifies request-scoped safety refusals from @@ -114,8 +116,9 @@ func isGrokAccountAccessCode(value string) bool { "subscription_required", "entitlement_required", "not_entitled", - "plan_required", - "permission_denied": + "plan_required": + // permission-denied is omitted: xAI reuses it for both entitlement + // refusals and request-scoped safety blocks, so the message decides. return true default: return false @@ -171,6 +174,7 @@ func grokContentPolicyMessage(value string) bool { "prompt violates policy", "input violates content policy", "input violates policy", + "violates usage guidelines", } { if strings.Contains(lower, phrase) { return true @@ -197,14 +201,73 @@ func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, r if isGrokContentPolicyRejection(statusCode, responseBody) { return false } + // A 422 emitted by xAI's ModelInput decoder is account/runtime compatibility, + // not quota exhaustion. Another account may run a different upstream build, + // so fail over without applying an account cooldown. + if isGrokDecoderCompatibilityError(statusCode, responseBody) { + return true + } decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") switch decision.Class { - case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity: + case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity, GrokFailureCompatibility: return decision.ShouldFailover } return s.shouldFailoverUpstreamError(statusCode) } +func isGrokDecoderCompatibilityError(statusCode int, responseBody []byte) bool { + if statusCode != http.StatusUnprocessableEntity || len(responseBody) == 0 { + return false + } + for _, candidate := range grokStructuredErrorMessageCandidates(responseBody) { + message := strings.ToLower(candidate) + decoderSignal := strings.Contains(message, "untagged enum") || + strings.Contains(message, "decode") || + strings.Contains(message, "deserialize") || + strings.Contains(message, "deserializ") || + strings.Contains(message, "decoder") + inputSignal := strings.Contains(message, "modelinput") || + strings.Contains(message, "model input") || + strings.Contains(message, "input[") || + strings.Contains(message, "input.") + messageContentSignal := (strings.Contains(message, "messages[") || + strings.Contains(message, "messages.")) && + strings.Contains(message, "content") && + strings.Contains(message, "did not match any variant") + if decoderSignal && (inputSignal || messageContentSignal) { + return true + } + } + return false +} + +func grokStructuredErrorMessageCandidates(body []byte) []string { + candidates := make([]string, 0, 6) + appendCandidate := func(result gjson.Result) { + if !result.Exists() { + return + } + value := strings.TrimSpace(result.String()) + if value != "" { + candidates = append(candidates, value) + } + } + appendCandidate(gjson.GetBytes(body, "error.message")) + appendCandidate(gjson.GetBytes(body, "error.error")) + errorNode := gjson.GetBytes(body, "error") + if errorNode.Type == gjson.String { + appendCandidate(errorNode) + } + appendCandidate(gjson.GetBytes(body, "message")) + appendCandidate(gjson.GetBytes(body, "detail")) + if !json.Valid(body) { + if plaintext := strings.TrimSpace(string(body)); plaintext != "" { + candidates = append(candidates, plaintext) + } + } + return candidates +} + // applyGrokForbiddenPolicy applies an administrator's existing temporary // unschedulable rules to a non-content 403. It reports true only when a rule // matched; unmatched responses retain the legacy entitlement cooldown. diff --git a/backend/internal/service/grok_upstream_errors_test.go b/backend/internal/service/grok_upstream_errors_test.go index 0dee756b72..3bd5ad3e73 100644 --- a/backend/internal/service/grok_upstream_errors_test.go +++ b/backend/internal/service/grok_upstream_errors_test.go @@ -83,6 +83,24 @@ func TestIsGrokContentPolicyRejection(t *testing.T) { body: `{"error":{"code":"policy_violation","message":"request blocked by policy"}}`, want: true, }, + { + name: "permission-denied usage guidelines is request scoped", + status: http.StatusForbidden, + body: `{"code":"permission-denied","error":"Content violates usage guidelines. "}`, + want: true, + }, + { + name: "permission-denied entitlement stays on the account path", + status: http.StatusForbidden, + body: `{"code":"permission-denied","error":"Access to the chat endpoint is denied"}`, + want: false, + }, + { + name: "structured account code overrides usage guidelines phrase", + status: http.StatusForbidden, + body: `{"error":{"code":"account_suspended","message":"Content violates usage guidelines."}}`, + want: false, + }, { name: "wrong status", status: http.StatusBadRequest, @@ -279,6 +297,21 @@ func TestGrokContentPolicySSEErrorDoesNotMutateOrFailover(t *testing.T) { require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } +func TestGrokPermissionDeniedContentRefusalDoesNotMutateOrFailover(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 4785, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"code":"permission-denied","error":"Content violates usage guidelines. "}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) + + require.Zero(t, repo.tempUnschedCalls) + require.Zero(t, repo.rateLimitedCalls) + require.Zero(t, repo.updateCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.shouldFailoverGrokUpstreamError(http.StatusForbidden, body)) +} + func TestHandleGrokAccountUpstreamErrorEntitlement403KeepsDefaultCooldown(t *testing.T) { repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} diff --git a/backend/internal/service/grok_upstream_failure.go b/backend/internal/service/grok_upstream_failure.go index e7bed08bf0..53d9d5500a 100644 --- a/backend/internal/service/grok_upstream_failure.go +++ b/backend/internal/service/grok_upstream_failure.go @@ -26,6 +26,10 @@ const ( GrokFailureRateLimit GrokUpstreamFailureClass = "rate_limit" GrokFailureAuth GrokUpstreamFailureClass = "auth_error" GrokFailureServer GrokUpstreamFailureClass = "server_error" + // GrokFailureCompatibility represents a request-history/body-shape that is + // incompatible with the selected account or upstream replay contract. It + // is account-independent: fail over, but never quarantine the pool. + GrokFailureCompatibility GrokUpstreamFailureClass = "compatibility_error" ) // GrokUpstreamFailureDecision is a pure classification result. Callers map it @@ -111,6 +115,20 @@ func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedM } } + // Responses replay/compaction payloads can be rejected by one Grok + // deployment while the same request is valid on another account. Treat + // these precise decoder/content-shape failures as account compatibility + // errors, rather than durable account health failures. + if isGrokCompatibilityError(statusCode, low, code) { + return GrokUpstreamFailureDecision{ + Class: GrokFailureCompatibility, + Model: model, + ShouldFailover: true, + ShouldCooldown: false, + Reason: firstNonEmpty(text, "grok response compatibility error"), + } + } + // Empty HTTP 200 / empty model output (often rewritten to synthetic 502). if isGrokEmptyModelOutputText(low) || isGrokEmptyModelOutputCode(code) { return GrokUpstreamFailureDecision{ @@ -129,7 +147,7 @@ func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedM return GrokUpstreamFailureDecision{ Class: GrokFailureModelCapacity, Model: model, - Cooldown: 3 * time.Minute, + Cooldown: time.Minute, ShouldCooldown: true, ShouldFailover: true, BlockModel: false, @@ -355,7 +373,8 @@ func isGrokBillingQuotaText(low string) bool { if strings.Contains(low, "payment") && (strings.Contains(low, "required") || strings.Contains(low, "fail")) { return true } - if strings.Contains(low, "spending limit") || strings.Contains(low, "run out of credits") || strings.Contains(low, "out of credits") { + if strings.Contains(low, "spending limit") || strings.Contains(low, "run out of credits") || strings.Contains(low, "out of credits") || + (strings.Contains(low, "need a grok subscription") || strings.Contains(low, "need grok subscription")) { return true } if strings.Contains(low, "余额不足") || strings.Contains(low, "欠费") || strings.Contains(low, "需要付费") { @@ -364,6 +383,86 @@ func isGrokBillingQuotaText(low string) bool { return false } +// grokRetryableOnSameAccount marks transient 429 classes for the shared +// failover loop. Capacity and ordinary throttles are request/model pressure, +// not evidence that the credential is invalid, so a bounded retry on the same +// account is preferable before switching accounts. Free-usage and billing +// exhaustion deliberately skip same-account retry and fail over immediately. +func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody []byte) bool { + if account == nil || !account.IsGrok() { + return false + } + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + switch decision.Class { + case GrokFailureFreeUsage, GrokFailureBilling, GrokFailureCompatibility: + // Quota/entitlement exhaustion is account state, not transient + // pressure. Retrying the same account only repeats the failure. + return false + case GrokFailureModelCapacity: + if statusCode == http.StatusTooManyRequests { + return true + } + } + return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) +} + +func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody []byte) (bool, time.Duration, time.Time, int) { + if !grokRetryableOnSameAccount(account, statusCode, responseBody) { + return false, 0, time.Time{}, 0 + } + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + if decision.Class != GrokFailureModelCapacity { + return true, 0, time.Time{}, 0 + } + // The error is reconstructed after every upstream attempt, so a deadline + // stored on the error cannot provide a request-wide window. Cap capacity + // retries explicitly to one replay; this remains effective even when the + // first attempt itself takes longer than the nominal 30-second window. + return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second), 1 +} + +// shouldMarkGrokTeamModelRateLimit controls the process-local sibling-account +// overlay. Model-capacity responses are request pressure, not a team quota; +// marking them would hide healthy sibling credentials while the bounded +// same-account retry is still in progress. Ordinary 429s and free-usage +// exhaustion retain the existing quota/team isolation behavior. +func shouldMarkGrokTeamModelRateLimit(statusCode int, responseBody []byte) bool { + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + if decision.Class == GrokFailureModelCapacity { + return false + } + return statusCode == http.StatusTooManyRequests || decision.Class == GrokFailureFreeUsage +} + +func isGrokCompatibilityError(statusCode int, low, code string) bool { + if statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity { + return false + } + combined := strings.ToLower(strings.TrimSpace(low + " " + code)) + // Compaction blobs are account/session-bound and frequently fail with 400 + // or 422 after a reconnect. Also cover xAI's JSON decoder shape errors. + for _, phrase := range []string{ + "could not decode the compaction blob", + "cannot decode the compaction blob", + "decode the compaction blob", + "ensure it is unmodified from the compact response", + "compaction blob", + } { + if strings.Contains(combined, phrase) { + return true + } + } + for _, marker := range []string{ + "invalid_compaction", + "compaction_decode_error", + } { + if strings.Contains(combined, marker) { + return true + } + } + return false +} + func isGrokModelCapacityText(low string) bool { return strings.Contains(low, "capacity") || strings.Contains(low, "overloaded") || @@ -489,10 +588,11 @@ func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision( case GrokFailureEmptyUpstream: reason = "grok empty model output" case GrokFailureModelCapacity: - if persistGrokTransientModelCooldown(account, decision) { - return true - } - reason = "grok model capacity" + // Capacity is scoped to the requested model. Never persist an account-wide + // unschedulable state for this transient class; the failover loop performs + // a bounded same-account retry before selecting another account. + _ = persistGrokTransientModelCooldown(account, decision) + return true case GrokFailureRateLimit: // Pure 429 without free-usage language keeps the existing rate-limit // snapshot path (Retry-After / quota headers). Body-only rate-limit @@ -501,6 +601,11 @@ func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision( return false case GrokFailureServer: reason = "grok upstream temporary error" + case GrokFailureCompatibility: + // Deliberately no account mutation. The caller uses ShouldFailover to + // retry another account; cooling a pool for a request-shape mismatch + // would remove healthy accounts. + return true default: return false } diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go index 0bd1063577..4f6514cde1 100644 --- a/backend/internal/service/grok_upstream_failure_test.go +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -54,6 +54,14 @@ func TestClassifyGrokUpstreamFailure_EmptyUpstream(t *testing.T) { require.Equal(t, 4*time.Minute, d.Cooldown) } +func TestClassifyGrokUpstreamFailure_ModelCapacityUsesShortCooldown(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusTooManyRequests, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`), "grok-4.6") + require.Equal(t, GrokFailureModelCapacity, d.Class) + require.Equal(t, time.Minute, d.Cooldown) + require.False(t, d.BlockModel) +} + func TestClassifyGrokUpstreamFailure_Billing(t *testing.T) { d := classifyGrokUpstreamFailure(http.StatusForbidden, []byte(`{"code":"personal-team-blocked:spending-limit","error":"spending limit reached"}`), "") require.Equal(t, GrokFailureBilling, d.Class) @@ -61,6 +69,62 @@ func TestClassifyGrokUpstreamFailure_Billing(t *testing.T) { require.True(t, d.ShouldFailover) } +func TestClassifyGrokUpstreamFailure_GrokSubscriptionRequiredIsBilling(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusPaymentRequired, + []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`), "grok-4.6") + require.Equal(t, GrokFailureBilling, d.Class) + require.True(t, d.ShouldFailover) + require.True(t, d.ShouldCooldown) +} + +func TestGrokRetryableOnSameAccount_CapacityAndRateLimit(t *testing.T) { + account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth} + require.True(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))) + require.False(t, grokRetryableOnSameAccount(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"rate limit exceeded"}}`))) + require.False(t, grokRetryableOnSameAccount(account, http.StatusPaymentRequired, + []byte(`{"error":{"message":"You have run out of credits or need a Grok subscription"}}`))) + poolAccount := &Account{ID: 9108, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"pool_mode": true}} + require.False(t, grokRetryableOnSameAccount(poolAccount, http.StatusTooManyRequests, + []byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`)), + "pool free-usage must fail over instead of retrying the exhausted account") + require.False(t, grokRetryableOnSameAccount(account, http.StatusBadRequest, + []byte(`{"error":{"message":"capacity field is invalid"}}`))) + nonGrok := &Account{ID: 9106, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + require.False(t, grokRetryableOnSameAccount(nonGrok, http.StatusTooManyRequests, + []byte(`{"error":{"message":"model at capacity"}}`))) +} + +func TestShouldMarkGrokTeamModelRateLimit_ExcludesCapacity(t *testing.T) { + require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`))) + require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusTooManyRequests, + []byte(`{"error":{"message":"rate limit exceeded"}}`))) + require.True(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest, + []byte(`{"error":{"code":"subscription:free-usage-exhausted"}}`))) + require.False(t, shouldMarkGrokTeamModelRateLimit(http.StatusBadRequest, + []byte(`{"error":{"message":"invalid request"}}`))) +} + +func TestGrokSameAccountRetryMetadata_CapacityDeadline(t *testing.T) { + account := &Account{ID: 9107, Platform: PlatformGrok, Type: AccountTypeOAuth} + retryable, delay, deadline, retryMax := grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"model capacity exceeded"}}`)) + require.True(t, retryable) + require.Equal(t, 500*time.Millisecond, delay) + require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, 2*time.Second) + require.Equal(t, 1, retryMax) + + retryable, delay, deadline, retryMax = grokSameAccountRetryMetadata(account, http.StatusTooManyRequests, + []byte(`{"error":{"message":"rate limit exceeded"}}`)) + require.False(t, retryable) + require.Zero(t, delay) + require.True(t, deadline.IsZero()) + require.Zero(t, retryMax) +} + func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) { d := classifyGrokUpstreamFailure(http.StatusBadRequest, []byte(`{"error":{"message":"invalid tool schema"}}`), "") require.Equal(t, GrokFailureNone, d.Class) @@ -75,12 +139,48 @@ func TestClassifyGrokUpstreamFailure_FreeUsageWinsOver5xx(t *testing.T) { require.NotEqual(t, GrokFailureServer, d.Class) } +func TestClassifyGrokUpstreamFailure_CompatibilityDoesNotCooldown(t *testing.T) { + cases := []string{ + `{"error":{"message":"Could not decode the compaction blob. Ensure it is unmodified from the compact response"}}`, + `{"code":"compaction_decode_error","message":"invalid response history"}`, + } + for _, body := range cases { + d := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, []byte(body), "grok-4.6") + require.Equal(t, GrokFailureCompatibility, d.Class, body) + require.True(t, d.ShouldFailover, body) + require.False(t, d.ShouldCooldown, body) + require.Zero(t, d.Cooldown, body) + } +} + +func TestClassifyGrokUpstreamFailure_CompatibilityRequiresClientError(t *testing.T) { + body := []byte(`{"error":{"message":"upstream failed while handling the compaction blob"}}`) + for _, status := range []int{http.StatusBadGateway, http.StatusInternalServerError} { + d := classifyGrokUpstreamFailure(status, body, "grok-4.6") + require.NotEqual(t, GrokFailureCompatibility, d.Class) + require.True(t, d.ShouldCooldown) + } +} + +func TestClassifyGrokUpstreamFailure_GenericShapeErrorDoesNotFailover(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusBadRequest, + []byte(`{"error":{"message":"data did not match any variant of the untagged enum content"}}`), "grok-4.6") + require.NotEqual(t, GrokFailureCompatibility, d.Class) + require.False(t, d.ShouldFailover) +} + func TestShouldFailoverGrokUpstreamError_FreeUsageBody(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`) require.True(t, svc.shouldFailoverGrokUpstreamError(http.StatusBadRequest, body)) } +func TestShouldFailoverGrokUpstreamError_CompatibilityBody(t *testing.T) { + svc := &OpenAIGatewayService{} + body := []byte(`{"error":{"message":"Could not decode the compaction blob"}}`) + require.True(t, svc.shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body)) +} + func TestShouldFailoverGrokUpstreamError_ContentPolicyStillNoFailover(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`) @@ -149,6 +249,19 @@ func TestHandleGrokAccountUpstreamError_MultiAgentCapacityBlocksOnlyThatModel(t require.False(t, isGrokModelQuotaBlocked(account.ID, "grok-4.5", time.Now())) } +func TestHandleGrokAccountUpstreamError_CapacityNeverCoolsAccount(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9121, Platform: PlatformGrok, Type: AccountTypeOAuth} + ctx := withGrokTeamRateLimitModel(context.Background(), "grok-4.6") + + svc.handleGrokAccountUpstreamError(ctx, account, http.StatusTooManyRequests, nil, + []byte(`{"error":{"message":"The model is currently at capacity due to high demand"}}`)) + + require.Zero(t, repo.tempUnschedCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + func TestHandleGrokAccountUpstreamError_FreeUsageDoesNotCoolPoolMode(t *testing.T) { repo := &grokQuotaAccountRepo{} svc := &OpenAIGatewayService{accountRepo: repo} diff --git a/backend/internal/service/grok_upstream_headers.go b/backend/internal/service/grok_upstream_headers.go index cdfb54bcbf..4b24e4c13b 100644 --- a/backend/internal/service/grok_upstream_headers.go +++ b/backend/internal/service/grok_upstream_headers.go @@ -10,10 +10,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) -// grokUpstreamUserAgent is kept for compatibility with older Grok request -// tests. Current requests use the pinned default UA from this package. -const grokUpstreamUserAgent = "sub2api-grok/1.0" - // Fixed CLI identity aliases — single source of truth is internal/pkg/xai. const ( grokClientVersionHeader = xai.CLIStableVersion diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index 83bd6e70fc..b2de6002cf 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -9,6 +9,7 @@ type HTTPUpstreamProfile string const ( HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" + HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" ) type httpUpstreamProfileContextKey struct{} @@ -35,7 +36,7 @@ func HTTPUpstreamProfileFromContext(ctx context.Context) HTTPUpstreamProfile { return HTTPUpstreamProfileDefault } switch profile { - case HTTPUpstreamProfileOpenAI: + case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok: return profile default: return HTTPUpstreamProfileDefault diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index 30da6ab6ba..927fd08765 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -293,11 +293,16 @@ func openAIRequestBodyImageGenerationToolNeedsNormalization(body []byte) bool { if openAIJSONString(item.Get("type")) != "image_generation" { return true } - // 只有旧字段需要迁移时才进入 map 修改,纯计费读取保持 raw 路径。 + // 只有旧字段或明确的模型不兼容字段需要修正时才进入 map 修改。 if item.Get("format").Exists() || item.Get("compression").Exists() { needsNormalization = true return false } + imageModel := strings.ToLower(strings.TrimSpace(item.Get("model").String())) + if strings.HasPrefix(imageModel, "gpt-image-2") && item.Get("input_fidelity").Exists() { + needsNormalization = true + return false + } return true }) return needsNormalization diff --git a/backend/internal/service/kimi_anthropic_usage_test.go b/backend/internal/service/kimi_anthropic_usage_test.go new file mode 100644 index 0000000000..458ba9fb39 --- /dev/null +++ b/backend/internal/service/kimi_anthropic_usage_test.go @@ -0,0 +1,248 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" +) + +func TestParseSSEUsagePassthroughNormalizesKimiPromptUsage(t *testing.T) { + usage := &ClaudeUsage{} + + parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"prompt_tokens":173306,"cached_tokens":0}}}`, usage) + require.Equal(t, 173306, usage.InputTokens) + require.Zero(t, usage.CacheReadInputTokens) + + parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":250,"cache_creation_input_tokens":0,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, usage) + require.Equal(t, 250, usage.InputTokens, "Kimi message_delta input_tokens is already the uncached bucket") + require.Equal(t, 173056, usage.CacheReadInputTokens) + require.Equal(t, 166, usage.OutputTokens) +} + +func TestParseSSEUsagePassthroughKimiFullyCachedInputReplacesStartTotal(t *testing.T) { + usage := &ClaudeUsage{} + + parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306}}}`, usage) + parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":0,"cache_read_input_tokens":173306,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306}}`, usage) + + require.Zero(t, usage.InputTokens, "an explicit zero uncached bucket must not retain message_start's total") + require.Equal(t, 173306, usage.CacheReadInputTokens) +} + +func TestParseClaudeUsageFromResponseBodyNormalizesCNProviderAliases(t *testing.T) { + tests := []struct { + name string + body string + wantInput int + wantCacheRead int + wantOutput int + }{ + { + name: "Kimi top-level cached_tokens", + body: `{"usage":{"input_tokens":173306,"output_tokens":166,"cache_read_input_tokens":173056,"prompt_tokens":173306,"cached_tokens":173056}}`, + wantInput: 250, + wantCacheRead: 173056, + wantOutput: 166, + }, + { + name: "GLM nested prompt cache details", + body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + wantCacheRead: 800, + wantOutput: 300, + }, + { + name: "DeepSeek prompt cache hit and miss buckets", + body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + wantCacheRead: 800, + wantOutput: 300, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := parseClaudeUsageFromResponseBody([]byte(tt.body)) + require.Equal(t, tt.wantInput, usage.InputTokens) + require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens) + require.Equal(t, tt.wantOutput, usage.OutputTokens) + }) + } +} + +func TestParseSSEUsagePassthroughNormalizesGLMAndDeepSeekAliases(t *testing.T) { + tests := []struct { + name string + data string + wantInput int + wantCacheRead int + }{ + { + name: "GLM", + data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + wantCacheRead: 800, + }, + { + name: "DeepSeek", + data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + wantCacheRead: 800, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := &ClaudeUsage{} + parseSSEUsagePassthrough(tt.data, usage) + require.Equal(t, tt.wantInput, usage.InputTokens) + require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens) + require.Equal(t, 30, usage.OutputTokens) + }) + } +} + +func TestMergeAnthropicUsageNormalizesKimiStreamForOpenAIBilling(t *testing.T) { + var start apicompat.AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306,"cached_tokens":0}}}`), &start)) + var delta apicompat.AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(`{"type":"message_delta","usage":{"input_tokens":250,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`), &delta)) + + usage := &ClaudeUsage{} + mergeAnthropicUsage(usage, start.Message.Usage) + mergeAnthropicUsage(usage, *delta.Usage) + require.Equal(t, 250, usage.InputTokens) + require.Equal(t, 173056, usage.CacheReadInputTokens) + + openAIUsage := claudeUsageToOpenAIUsage(usage) + require.Equal(t, 173306, openAIUsage.InputTokens, "OpenAI gateway expects an inclusive input total") + require.Equal(t, 250, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + require.Equal(t, 166, openAIUsage.OutputTokens) +} + +func TestMergeAnthropicUsageNormalizesGLMAndDeepSeekAliases(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "GLM", + raw: `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}`, + }, + { + name: "DeepSeek", + raw: `{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var src apicompat.AnthropicUsage + require.NoError(t, json.Unmarshal([]byte(tt.raw), &src)) + + usage := &ClaudeUsage{} + mergeAnthropicUsage(usage, src) + require.Equal(t, 400, usage.InputTokens) + require.Equal(t, 800, usage.CacheReadInputTokens) + + openAIUsage := claudeUsageToOpenAIUsage(usage) + require.Equal(t, 1200, openAIUsage.InputTokens) + require.Equal(t, 400, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + }) + } +} + +func TestClaudeUsageToOpenAIUsagePreservesCNProviderNativeAnthropicBuckets(t *testing.T) { + tests := []struct { + name string + usage ClaudeUsage + wantTotal int + wantUncached int + }{ + { + name: "GLM", + usage: ClaudeUsage{ + InputTokens: 2, + OutputTokens: 302, + CacheCreationInputTokens: 733, + CacheReadInputTokens: 376156, + }, + wantTotal: 376891, + wantUncached: 2, + }, + { + name: "DeepSeek", + usage: ClaudeUsage{ + InputTokens: 400, + OutputTokens: 30, + CacheReadInputTokens: 800, + }, + wantTotal: 1200, + wantUncached: 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + openAIUsage := claudeUsageToOpenAIUsage(&tt.usage) + require.Equal(t, tt.wantTotal, openAIUsage.InputTokens) + require.Equal(t, tt.wantUncached, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + require.Equal(t, tt.usage.CacheReadInputTokens, openAIUsage.CacheReadInputTokens) + require.Equal(t, tt.usage.CacheCreationInputTokens, openAIUsage.CacheCreationInputTokens) + }) + } +} + +func TestCNProviderAnthropicUsageBillsUncachedInput(t *testing.T) { + tests := []struct { + name string + model string + body string + wantInput int + }{ + { + name: "Kimi", + model: "k3", + body: `{"usage":{"input_tokens":173306,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, + wantInput: 250, + }, + { + name: "GLM", + model: "glm-5.2", + body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + }, + { + name: "DeepSeek", + model: "deepseek-v4-flash", + body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + }, + } + + billing := NewBillingService(&config.Config{}, nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claudeUsage := parseClaudeUsageFromResponseBody([]byte(tt.body)) + openAIUsage := claudeUsageToOpenAIUsage(claudeUsage) + uncachedInput := max(openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens, 0) + require.Equal(t, tt.wantInput, uncachedInput) + + cost, err := billing.CalculateCost(tt.model, UsageTokens{ + InputTokens: uncachedInput, + OutputTokens: openAIUsage.OutputTokens, + CacheCreationTokens: openAIUsage.CacheCreationInputTokens, + CacheReadTokens: openAIUsage.CacheReadInputTokens, + }, 1) + require.NoError(t, err) + require.Positive(t, cost.InputCost, "uncached input must contribute to the final charge") + + pricing, err := billing.GetModelPricing(tt.model) + require.NoError(t, err) + require.InDelta(t, float64(tt.wantInput)*pricing.InputPricePerToken, cost.InputCost, 1e-12) + }) + } +} diff --git a/backend/internal/service/leader_lock_test.go b/backend/internal/service/leader_lock_test.go index aba1c62c3e..a2adf01267 100644 --- a/backend/internal/service/leader_lock_test.go +++ b/backend/internal/service/leader_lock_test.go @@ -92,10 +92,10 @@ func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) _, _ = cache.TryAcquireLeaderLock(context.Background(), subscriptionExpiryReminderLeaderLockKey, "peer", time.Minute) repo := &subscriptionExpiryRepoStub{} - settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}} svc := NewSubscriptionExpiryService(repo, time.Minute) svc.SetSettingRepository(settingRepo) - svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil)) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil))) svc.SetLeaderLock(cache, nil) svc.sendExpiryReminders(context.Background()) @@ -105,10 +105,10 @@ func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) func TestSubscriptionExpiryService_ReminderScansWhenLeader(t *testing.T) { repo := &subscriptionExpiryRepoStub{} - settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}} svc := NewSubscriptionExpiryService(repo, time.Minute) svc.SetSettingRepository(settingRepo) - svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil)) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil))) svc.SetLeaderLock(&fakeLeaderLockCache{}, nil) svc.sendExpiryReminders(context.Background()) @@ -127,10 +127,10 @@ func TestSubscriptionExpiryService_ReminderRunsEveryCycleSingleInstance(t *testi for name, cache := range cases { t.Run(name, func(t *testing.T) { repo := &subscriptionExpiryRepoStub{} - settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}} svc := NewSubscriptionExpiryService(repo, time.Minute) svc.SetSettingRepository(settingRepo) - svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil)) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil))) svc.SetLeaderLock(cache, nil) // Three consecutive cycles, mimicking the ticker loop. diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go new file mode 100644 index 0000000000..fe2252c594 --- /dev/null +++ b/backend/internal/service/model_plaza_service.go @@ -0,0 +1,364 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" +) + +// PlazaOfficialPricing 模型广场展示用的官方参考价(USD per token),与计费同源: +// LiteLLM → 内置兜底价卡 → 模型策略。字段为 nil 表示该项缺失(0 视为未配置)。 +type PlazaOfficialPricing struct { + InputPrice *float64 + OutputPrice *float64 + CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation) + CacheWrite1hPrice *float64 // 1h 缓存写入,仅计费会区分 5m/1h 时给出 + CacheReadPrice *float64 + // Intervals 官方长上下文阶梯(多档时给出),不受分组开关影响。 + Intervals []PricingInterval +} + +// PlazaModel 模型广场中单个模型条目:按实收口径合成的展示定价 + 官方参考价。 +type PlazaModel struct { + Name string + Platform string + Pricing *ChannelModelPricing + OfficialPricing *PlazaOfficialPricing + // LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。 + LongContextBasis ContextPricingBasis + // TimePricing 计费会生效的分时倍率时段;无分时为 nil。 + TimePricing *TimePricingSchedule +} + +// PlazaGroup 模型广场中以分组为顶层的条目。 +// +// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 +// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 +// 具体平台),与「可用渠道」页口径一致。 +type PlazaGroup struct { + ID int64 + Name string + Description string + Platform string + SubscriptionType string + RateMultiplier float64 + PeakRateEnabled bool + PeakStart string + PeakEnd string + PeakRateMultiplier float64 + IsExclusive bool + // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付 + // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 + ImageRateIndependent bool + ImageRateMultiplier float64 + // LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。 + LongContextPricingEnabled bool + Models []PlazaModel +} + +// ModelPlazaService 聚合模型广场数据。 +// +// 模型枚举来自渠道配置;token 模型的展示单价与阶梯由 BillingService 的阶梯表 +// 查询给出(与扣费走同一条解析链与计费函数),图片/按次模型沿用渠道/分组档位价。 +type ModelPlazaService struct { + channelRepo ChannelRepository + groupRepo GroupRepository + pricingService *PricingService + billingService *BillingService + resolver *ModelPricingResolver +} + +// NewModelPlazaService 创建模型广场服务。 +func NewModelPlazaService( + channelRepo ChannelRepository, + groupRepo GroupRepository, + pricingService *PricingService, + billingService *BillingService, + resolver *ModelPricingResolver, +) *ModelPlazaService { + return &ModelPlazaService{ + channelRepo: channelRepo, + groupRepo: groupRepo, + pricingService: pricingService, + billingService: billingService, + resolver: resolver, + } +} + +// ListGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。 +// +// 模型枚举口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、 +// 平台隔离),仅把顶层从渠道换成分组: +// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定; +// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换; +// - token 模型的单价与阶梯按实收口径合成(见 ResolveContextPricingSchedule), +// 图片计费模型的档位价按实收口径合成(见 plazaImageDisplayPricing); +// - 每个模型附带官方参考价(查不到为 nil); +// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称), +// 组内模型按名称排序。 +// +// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。 +func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error) { + channels, err := s.channelRepo.ListAll(ctx) + if err != nil { + return nil, fmt.Errorf("list channels: %w", err) + } + groups, err := s.groupRepo.ListActive(ctx) + if err != nil { + return nil, fmt.Errorf("list active groups: %w", err) + } + + sort.SliceStable(channels, func(i, j int) bool { + return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name) + }) + + byGroup := make(map[int64]*PlazaGroup, len(groups)) + groupEnt := make(map[int64]*Group, len(groups)) + order := make([]int64, 0, len(groups)) + for i := range groups { + g := &groups[i] + byGroup[g.ID] = &PlazaGroup{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, + ImageRateIndependent: g.ImageRateIndependent, + ImageRateMultiplier: g.ImageRateMultiplier, + LongContextPricingEnabled: g.LongContextPricingEnabled, + } + groupEnt[g.ID] = g + order = append(order, g.ID) + } + + type modelKey struct { + platform string + name string + } + // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models + modelIdx := make(map[int64]map[modelKey]int, len(groups)) + for i := range channels { + ch := &channels[i] + if ch.Status != StatusActive { + continue + } + ch.normalizeBillingModelSource() + supported := ch.SupportedModels() + fillGlobalPricingFallback(s.pricingService, supported) + + for _, gid := range ch.GroupIDs { + pg, ok := byGroup[gid] + if !ok { + continue + } + idx := modelIdx[gid] + if idx == nil { + idx = make(map[modelKey]int, len(supported)) + modelIdx[gid] = idx + } + for j := range supported { + m := supported[j] + if pg.Platform == PlatformComposite { + if !isConcreteRequestPlatform(m.Platform) { + continue + } + } else if m.Platform != pg.Platform { + continue + } + key := modelKey{platform: m.Platform, name: m.Name} + if at, seen := idx[key]; seen { + // 先见者胜;仅当已存条目无定价而新条目有定价时升级。 + if pg.Models[at].Pricing == nil && m.Pricing != nil { + pg.Models[at].Pricing = m.Pricing + } + continue + } + idx[key] = len(pg.Models) + pg.Models = append(pg.Models, PlazaModel{ + Name: m.Name, + Platform: m.Platform, + Pricing: m.Pricing, + }) + } + } + } + + officialMemo := make(map[string]*PlazaOfficialPricing) + out := make([]PlazaGroup, 0, len(order)) + for _, gid := range order { + pg := byGroup[gid] + if len(pg.Models) == 0 { + continue + } + sort.SliceStable(pg.Models, func(i, j int) bool { + if pg.Models[i].Name != pg.Models[j].Name { + return pg.Models[i].Name < pg.Models[j].Name + } + return pg.Models[i].Platform < pg.Models[j].Platform + }) + g := groupEnt[gid] + for j := range pg.Models { + s.fillDisplayPricing(ctx, &pg.Models[j], g) + pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, pg.Models[j].Name, officialMemo) + } + out = append(out, *pg) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].RateMultiplier != out[j].RateMultiplier { + return out[i].RateMultiplier < out[j].RateMultiplier + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// fillDisplayPricing 把模型的展示定价换成实收口径: +// token 模型取计费阶梯表(单价与档位均由真实计费函数得出), +// 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。 +func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) { + if s.billingService != nil && s.resolver != nil { + sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{ + Model: m.Name, + Group: g, + Platform: m.Platform, + }) + if err == nil && sched != nil && len(sched.Tiers) > 0 { + m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) + if len(sched.Tiers) > 1 { + m.LongContextBasis = sched.Basis + } + m.TimePricing = sched.TimePricing + return + } + } + m.Pricing = plazaImageDisplayPricing(m.Pricing, g) +} + +// plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: +// 平价取首档单价,多档时 Intervals 逐档给出绝对单价;图片/按次字段沿用原始定价。 +func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSchedule) *ChannelModelPricing { + out := &ChannelModelPricing{BillingMode: BillingModeToken} + if raw != nil { + out.ImageInputPrice = raw.ImageInputPrice + out.ImageOutputPrice = raw.ImageOutputPrice + out.PerRequestPrice = raw.PerRequestPrice + } + first := sched.Tiers[0] + out.InputPrice = first.Input + out.OutputPrice = first.Output + out.CacheWritePrice = first.CacheWrite + out.CacheReadPrice = first.CacheRead + if len(sched.Tiers) > 1 { + out.Intervals = plazaIntervalsFromTiers(sched.Tiers) + } + return out +} + +func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval { + intervals := make([]PricingInterval, 0, len(tiers)) + for i, t := range tiers { + intervals = append(intervals, PricingInterval{ + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + TierLabel: t.Label, + InputPrice: t.Input, + OutputPrice: t.Output, + CacheWritePrice: t.CacheWrite, + CacheReadPrice: t.CacheRead, + SortOrder: i, + }) + } + return intervals +} + +// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致: +// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。 +// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参 +// (渠道定价指针指向缓存共享数据)。 +func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing { + if p == nil || g == nil || p.BillingMode != BillingModeImage { + return p + } + if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil { + return p + } + channelTierPrice := func(label string) *float64 { + for i := range p.Intervals { + if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil { + return p.Intervals[i].PerRequestPrice + } + } + return p.PerRequestPrice + } + tiers := []struct { + label string + groupPrice *float64 + }{ + {"1K", g.ImagePrice1K}, + {"2K", g.ImagePrice2K}, + {"4K", g.ImagePrice4K}, + } + clone := *p + clone.Intervals = make([]PricingInterval, 0, len(tiers)) + for i, t := range tiers { + price := t.groupPrice + if price == nil { + price = channelTierPrice(t.label) + } + if price == nil { + continue + } + v := *price + clone.Intervals = append(clone.Intervals, PricingInterval{ + TierLabel: t.label, + PerRequestPrice: &v, + SortOrder: i, + }) + } + return &clone +} + +// lookupOfficialPricing 查询模型的官方参考价(与计费同源:LiteLLM → 内置兜底 → 模型策略), +// 带 memo 避免同名模型重复解析。官方阶梯按无分组、无渠道的口径查阶梯表。 +// billingService 为 nil(测试场景)或查不到时返回 nil。 +func (s *ModelPlazaService) lookupOfficialPricing(ctx context.Context, modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing { + if s.billingService == nil { + return nil + } + if cached, ok := memo[modelName]; ok { + return cached + } + var result *PlazaOfficialPricing + if mp, err := s.billingService.GetModelPricing(modelName); err == nil && mp != nil { + result = &PlazaOfficialPricing{ + InputPrice: nonZeroPtr(mp.InputPricePerToken), + OutputPrice: nonZeroPtr(mp.OutputPricePerToken), + CacheWritePrice: nonZeroPtr(mp.CacheCreationPricePerToken), + CacheReadPrice: nonZeroPtr(mp.CacheReadPricePerToken), + } + // 计费只在支持 5m/1h 分档时使用 1h 价,其余情况 1h 价对用户无意义。 + if mp.SupportsCacheBreakdown { + result.CacheWrite1hPrice = nonZeroPtr(mp.CacheCreation1hPrice) + } + if s.resolver != nil { + sched, schedErr := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{Model: modelName}) + if schedErr == nil && sched != nil && len(sched.Tiers) > 1 { + result.Intervals = plazaIntervalsFromTiers(sched.Tiers) + } + } + if result.InputPrice == nil && result.OutputPrice == nil && result.CacheWritePrice == nil && + result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil && len(result.Intervals) == 0 { + result = nil + } + } + memo[modelName] = result + return result +} diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/model_plaza_service_test.go similarity index 53% rename from backend/internal/service/channel_plaza_test.go rename to backend/internal/service/model_plaza_service_test.go index 554654149c..d54aca70ad 100644 --- a/backend/internal/service/channel_plaza_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -7,17 +7,16 @@ import ( "errors" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) -// newPlazaChannelService 构造 ListPlazaGroups 测试用的 ChannelService。 -func newPlazaChannelService(channels []Channel, groups []Group, pricing *PricingService) *ChannelService { +// newPlazaService 构造 ListGroups 测试用的 ModelPlazaService(不接计费服务:展示定价原样透传)。 +func newPlazaService(channels []Channel, groups []Group, pricing *PricingService) *ModelPlazaService { repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, } - svc := NewChannelService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, nil, nil) - svc.pricingService = pricing - return svc + return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, pricing, nil, nil) } func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel { @@ -46,8 +45,8 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { {ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1}, {ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5}, } - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1, "无模型的分组不应返回") require.Equal(t, int64(10), out[0].ID) @@ -71,8 +70,8 @@ func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}} // alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。 - svc := newPlazaChannelService([]Channel{priced, unpriced}, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService([]Channel{priced, unpriced}, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) require.Len(t, out[0].Models, 1) @@ -93,8 +92,8 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) { {ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1}, {ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1}, } - svc := newPlazaChannelService([]Channel{ch}, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService([]Channel{ch}, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) byName := map[string][]PlazaModel{} @@ -122,7 +121,7 @@ func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing } groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}} - out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) @@ -146,7 +145,7 @@ func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing {ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}, } - out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) @@ -173,8 +172,8 @@ func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) { inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet") inactive.Status = "inactive" groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}} - svc := newPlazaChannelService([]Channel{inactive}, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService([]Channel{inactive}, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Empty(t, out) } @@ -188,8 +187,8 @@ func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) { {ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1}, {ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5}, } - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 3) require.Equal(t, "cheap", out[0].Name, "倍率低者在前") @@ -213,8 +212,11 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) { plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"), } groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}} - svc := newPlazaChannelService(channels, groups, pricingSvc) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, pricingSvc) + // 官方价与计费同源:需要计费服务与解析器(官方参考不查渠道,解析器无需渠道服务)。 + svc.billingService = NewBillingService(&config.Config{}, pricingSvc) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) require.Len(t, out[0].Models, 3) @@ -256,8 +258,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1}, {ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1}, } - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) byName := map[string]PlazaGroup{} @@ -298,8 +300,8 @@ func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) { imgPrice := 0.02 channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, "openai", "gpt-5")} groups := []Group{{ID: 10, Name: "g", Platform: "openai", RateMultiplier: 1, ImagePrice1K: &imgPrice}} - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) p := out[0].Models[0].Pricing @@ -314,17 +316,180 @@ func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) { repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel }, } - svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := NewModelPlazaService(repo, &stubGroupRepoForAvailable{}, nil, nil, nil) + out, err := svc.ListGroups(context.Background()) require.Nil(t, out) require.ErrorIs(t, err, sentinel) - svc2 := NewChannelService( + svc2 := NewModelPlazaService( &mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }}, &stubGroupRepoForAvailable{listActiveErr: sentinel}, - nil, nil, + nil, nil, nil, ) - out2, err2 := svc2.ListPlazaGroups(context.Background()) + out2, err2 := svc2.ListGroups(context.Background()) require.Nil(t, out2) require.ErrorIs(t, err2, sentinel) } + +// newPlazaServiceWithBilling 构造接入计费服务与解析器的广场服务:解析器的渠道服务与广场共用同一份渠道数据。 +func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatforms map[int64]string, catalog *PricingService) *ModelPlazaService { + repo := &mockChannelRepository{ + listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, + getGroupPlatformsFn: func(ctx context.Context, _ []int64) (map[int64]string, error) { + return groupPlatforms, nil + }, + } + cs := NewChannelService(repo, nil, nil, nil) + bs := NewBillingService(&config.Config{}, catalog) + return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs)) +} + +func plazaModelsByName(models []PlazaModel) map[string]PlazaModel { + out := make(map[string]PlazaModel, len(models)) + for _, m := range models { + out[m.Name] = m + } + return out +} + +func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) { + // 同一渠道挂开启/关闭阶梯的两个分组:实付档位随分组开关,官方阶梯不受影响。 + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10, 20}, + ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken}}, + }} + groups := []Group{ + {ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true}, + {ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false}, + } + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + require.Len(t, out, 2) + + on, off := out[0], out[1] + require.True(t, on.LongContextPricingEnabled) + require.False(t, off.LongContextPricingEnabled) + + onModel := on.Models[0] + require.Equal(t, ContextPricingBasisWholeRequest, onModel.LongContextBasis) + require.Len(t, onModel.Pricing.Intervals, 2) + require.Equal(t, "≤272K", onModel.Pricing.Intervals[0].TierLabel) + require.Equal(t, ">272K", onModel.Pricing.Intervals[1].TierLabel) + require.InDelta(t, 2.5e-6, *onModel.Pricing.InputPrice, 1e-15) + require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].InputPrice, 1e-15) + require.InDelta(t, 22.5e-6, *onModel.Pricing.Intervals[1].OutputPrice, 1e-15) + require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].CacheWritePrice, 1e-15) + require.InDelta(t, 0.5e-6, *onModel.Pricing.Intervals[1].CacheReadPrice, 1e-15) + + offModel := off.Models[0] + require.Empty(t, offModel.LongContextBasis) + require.Empty(t, offModel.Pricing.Intervals) + require.InDelta(t, 2.5e-6, *offModel.Pricing.InputPrice, 1e-15) + + for _, m := range []PlazaModel{onModel, offModel} { + require.NotNil(t, m.OfficialPricing) + require.Len(t, m.OfficialPricing.Intervals, 2, "官方阶梯不受分组开关影响") + require.InDelta(t, 5e-6, *m.OfficialPricing.Intervals[1].InputPrice, 1e-15) + require.InDelta(t, 2.5e-6, *m.OfficialPricing.InputPrice, 1e-15) + } +} + +func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}}, + }} + groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub()) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + require.Len(t, out, 1) + m := out[0].Models[0] + require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis) + require.Len(t, m.Pricing.Intervals, 2) + require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel) + require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel) + require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15) + require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15) + // 官方参考不套用站内旧规则 + require.NotNil(t, m.OfficialPricing) + require.Empty(t, m.OfficialPricing.Intervals) +} + +func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) { + channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "claude-sonnet-4")} + groups := []Group{{ + ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true, + ModelPricing: []ChannelModelPricing{{Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6)}}, + }} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.InDelta(t, 1e-6, *m.Pricing.InputPrice, 1e-15, "分组价卡优先于渠道平价") + require.InDelta(t, 15e-6, *m.Pricing.OutputPrice, 1e-15, "卡未配置的项回落目录价") + require.Empty(t, m.Pricing.Intervals) +} + +func TestListGroups_ImageModelKeepsTierSynthesisWithBilling(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage, + PerRequestPrice: testPtrFloat64(0.04), + }}, + }} + groups := []Group{{ + ID: 10, Name: "g", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true, + ImagePrice1K: testPtrFloat64(0.02), + }} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.Equal(t, BillingModeImage, m.Pricing.BillingMode) + require.Empty(t, m.LongContextBasis) + require.Len(t, m.Pricing.Intervals, 3) + require.InDelta(t, 0.02, *m.Pricing.Intervals[0].PerRequestPrice, 1e-12) + require.InDelta(t, 0.04, *m.Pricing.Intervals[1].PerRequestPrice, 1e-12) +} + +func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) { + // 目录查不到的模型:计费按渠道平价(未配置项 $0),广场单档展示渠道平价,官方价为空。 + channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "unknown-model-xyz")} + groups := []Group{{ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.NotNil(t, m.Pricing) + require.InDelta(t, 3e-6, *m.Pricing.InputPrice, 1e-15) + require.Empty(t, m.Pricing.Intervals) + require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价") + require.Nil(t, m.OfficialPricing) +} + +func TestListGroups_TimePricingPassthrough(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + Platform: PlatformDeepseek, Models: []string{"deepseek-chat"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.28e-6), OutputPrice: testPtrFloat64(0.42e-6), + TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }}, + }} + groups := []Group{{ID: 10, Name: "cn", Platform: PlatformDeepseek, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformDeepseek}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.NotNil(t, m.TimePricing) + require.Equal(t, "Asia/Shanghai", m.TimePricing.Timezone) + require.Len(t, m.TimePricing.Periods, 1) + require.InDelta(t, 0.5, m.TimePricing.Periods[0].Multiplier, 1e-12) + // 展示单价为标准时段价 + require.InDelta(t, 0.28e-6, *m.Pricing.InputPrice, 1e-15) +} diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 52c6b930c5..62283e81b7 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -120,14 +120,8 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) resolved.Source = PricingSourceChannel resolved.channelPricing = chPricing r.applyTokenOverrides(chPricing, resolved) - if !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, chPricing) - } } else if input.GroupID != nil && r.channelService != nil { r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved) - if resolved.Source == PricingSourceChannel && !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, resolved.channelPricing) - } } return resolved @@ -172,20 +166,6 @@ func matchGroupModelPricing(group *Group, model string) *ChannelModelPricing { return wildcard } -func (r *ModelPricingResolver) applyFirstTokenTier(resolved *ResolvedPricing, config *ChannelModelPricing) { - if resolved == nil || len(resolved.Intervals) == 0 { - return - } - first := resolved.Intervals[0] - for _, interval := range resolved.Intervals[1:] { - if interval.MinTokens < first.MinTokens { - first = interval - } - } - resolved.BasePricing = intervalToModelPricing(&first, resolved.SupportsCacheBreakdown, config) - resolved.Intervals = nil -} - // resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价 func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) { pricing, err := r.billingService.GetModelPricing(model) @@ -221,31 +201,6 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI // applyTokenOverrides 应用 token 模式的渠道覆盖 func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) { - // 过滤掉所有价格字段都为空的无效 interval - validIntervals := filterValidIntervals(chPricing.Intervals) - - // 如果有有效的区间定价,使用区间 - if len(validIntervals) > 0 { - resolved.Intervals = validIntervals - // 区间不匹配时回退到 BasePricing,也需要覆盖图片价格 - if resolved.BasePricing == nil { - resolved.BasePricing = &ModelPricing{} - } else { - // 防止修改 fallbackPrices 中的共享指针 - cloned := *resolved.BasePricing - resolved.BasePricing = &cloned - } - if chPricing.ImageOutputPrice != nil { - resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice - } else { - resolved.BasePricing.ImageOutputPricePerToken = 0 - } - resolved.BasePricing.ImageOutputPriceExplicit = true - applyChannelImageInputPrice(chPricing, resolved.BasePricing) - return - } - - // 否则用 flat 字段覆盖 BasePricing if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} } else { @@ -254,25 +209,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin resolved.BasePricing = &cloned } - if chPricing.InputPrice != nil { - resolved.BasePricing.InputPricePerToken = *chPricing.InputPrice - resolved.BasePricing.InputPricePerTokenPriority = *chPricing.InputPrice - } - if chPricing.OutputPrice != nil { - resolved.BasePricing.OutputPricePerToken = *chPricing.OutputPrice - resolved.BasePricing.OutputPricePerTokenPriority = *chPricing.OutputPrice - } - if chPricing.CacheWritePrice != nil { - resolved.BasePricing.CacheCreationPricePerToken = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreationPricePerTokenPriority = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreationPriceExplicit = true - resolved.BasePricing.CacheCreation5mPrice = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreation1hPrice = *chPricing.CacheWritePrice - } - if chPricing.CacheReadPrice != nil { - resolved.BasePricing.CacheReadPricePerToken = *chPricing.CacheReadPrice - resolved.BasePricing.CacheReadPricePerTokenPriority = *chPricing.CacheReadPrice - } + applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing) + resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier + resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice @@ -281,6 +220,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.ImageOutputPriceExplicit = true applyChannelImageInputPrice(chPricing, resolved.BasePricing) + + // 区间未命中时回退到上面已经应用渠道覆盖的基础价。 + resolved.Intervals = filterValidIntervals(chPricing.Intervals) } // applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值; @@ -311,7 +253,9 @@ func filterValidIntervals(intervals []PricingInterval) []PricingInterval { for _, iv := range intervals { if iv.InputPrice != nil || iv.OutputPrice != nil || iv.CacheWritePrice != nil || iv.CacheReadPrice != nil || - iv.PerRequestPrice != nil { + iv.PerRequestPrice != nil || iv.InputMultiplier != nil || + iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil || + iv.CacheReadMultiplier != nil { valid = append(valid, iv) } } @@ -330,32 +274,57 @@ func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, tot return resolved.BasePricing } - return intervalToModelPricing(iv, resolved.SupportsCacheBreakdown, resolved.channelPricing) + pricing := intervalToModelPricing(iv, resolved.BasePricing, resolved.channelPricing) + // BasePricing 为 nil(仅配置区间)时拷贝不到该标志,从 resolved 回填, + // 保证 computeCacheCreationCost 的 5m/1h 分档判断不被区间路径吞掉。 + pricing.SupportsCacheBreakdown = resolved.SupportsCacheBreakdown + return pricing } // intervalToModelPricing 将区间定价转换为 ModelPricing -func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, chPricing *ChannelModelPricing) *ModelPricing { - pricing := &ModelPricing{ - SupportsCacheBreakdown: supportsCacheBreakdown, +func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *ChannelModelPricing) *ModelPricing { + pricing := &ModelPricing{} + if base != nil { + *pricing = *base + } + applyMultiplier := func(value float64, multiplier *float64) float64 { + if multiplier == nil { + return value + } + return value * *multiplier } if iv.InputPrice != nil { + pricing.InputPricePerTokenPriority = channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *iv.InputPrice) pricing.InputPricePerToken = *iv.InputPrice - pricing.InputPricePerTokenPriority = *iv.InputPrice + } else if iv.InputMultiplier != nil { + pricing.InputPricePerToken = applyMultiplier(pricing.InputPricePerToken, iv.InputMultiplier) + pricing.InputPricePerTokenPriority = applyMultiplier(pricing.InputPricePerTokenPriority, iv.InputMultiplier) } if iv.OutputPrice != nil { + pricing.OutputPricePerTokenPriority = channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *iv.OutputPrice) pricing.OutputPricePerToken = *iv.OutputPrice - pricing.OutputPricePerTokenPriority = *iv.OutputPrice + } else if iv.OutputMultiplier != nil { + pricing.OutputPricePerToken = applyMultiplier(pricing.OutputPricePerToken, iv.OutputMultiplier) + pricing.OutputPricePerTokenPriority = applyMultiplier(pricing.OutputPricePerTokenPriority, iv.OutputMultiplier) } if iv.CacheWritePrice != nil { + pricing.CacheCreationPricePerTokenPriority = channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *iv.CacheWritePrice) pricing.CacheCreationPricePerToken = *iv.CacheWritePrice - pricing.CacheCreationPricePerTokenPriority = *iv.CacheWritePrice pricing.CacheCreationPriceExplicit = true pricing.CacheCreation5mPrice = *iv.CacheWritePrice pricing.CacheCreation1hPrice = *iv.CacheWritePrice + } else if iv.CacheWriteMultiplier != nil { + pricing.CacheCreationPricePerToken = applyMultiplier(pricing.CacheCreationPricePerToken, iv.CacheWriteMultiplier) + pricing.CacheCreationPricePerTokenPriority = applyMultiplier(pricing.CacheCreationPricePerTokenPriority, iv.CacheWriteMultiplier) + pricing.CacheCreation5mPrice = applyMultiplier(pricing.CacheCreation5mPrice, iv.CacheWriteMultiplier) + pricing.CacheCreation1hPrice = applyMultiplier(pricing.CacheCreation1hPrice, iv.CacheWriteMultiplier) } if iv.CacheReadPrice != nil { + pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice) pricing.CacheReadPricePerToken = *iv.CacheReadPrice - pricing.CacheReadPricePerTokenPriority = *iv.CacheReadPrice + } else if iv.CacheReadMultiplier != nil { + pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier) + pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier) } // 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置 // (区间不携带图片输入价,与 image_output 一致)。 diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 11613fa9eb..3fb3e1c63f 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -142,7 +142,7 @@ func TestGPT56ExplicitZeroCacheWritePriceIsPreserved(t *testing.T) { }) t.Run("interval price", func(t *testing.T) { - pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, false, nil) + pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, &ModelPricing{}, nil) require.True(t, pricing.CacheCreationPriceExplicit) cost, err := bs.CalculateCostUnified(CostInput{ @@ -261,9 +261,9 @@ func TestResolve_WithChannelOverride_TokenFlat(t *testing.T) { require.Equal(t, "channel", resolved.Source) require.NotNil(t, resolved.BasePricing) require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerToken, 1e-12) - require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerTokenPriority, 1e-12) + require.Zero(t, resolved.BasePricing.InputPricePerTokenPriority) require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, resolved.BasePricing.OutputPricePerTokenPriority) } func TestResolve_WithChannelOverride_TokenPartialOverride(t *testing.T) { @@ -304,10 +304,12 @@ func TestResolve_WithChannelOverride_TokenWithIntervals(t *testing.T) { resolved := r.Resolve(context.Background(), PricingInput{ Model: "claude-sonnet-4", GroupID: groupIDPtr(), + Group: &Group{LongContextPricingEnabled: false}, }) require.NotNil(t, resolved) require.Equal(t, "channel", resolved.Source) + require.False(t, resolved.longContextPricingEnabled) require.Len(t, resolved.Intervals, 2) // GetIntervalPricing should use channel intervals @@ -532,6 +534,7 @@ func TestGetIntervalPricing_ChannelIntervalsNoMatch(t *testing.T) { Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(4e-6), Intervals: []PricingInterval{ // Only covers tokens > 50000 {MinTokens: 50000, MaxTokens: testPtrInt(200000), InputPrice: testPtrFloat64(9e-6)}, @@ -545,10 +548,10 @@ func TestGetIntervalPricing_ChannelIntervalsNoMatch(t *testing.T) { // Token count 1000 doesn't match any interval (1000 <= 50000 minTokens) pricing := r.GetIntervalPricing(resolved, 1000) - // Should fall back to BasePricing (from the billing service fallback) + // Should fall back to BasePricing after applying the channel default. require.NotNil(t, pricing) require.Equal(t, resolved.BasePricing, pricing) - require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12) // original base price + require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12) } // =========================================================================== @@ -689,6 +692,13 @@ func TestFilterValidIntervals(t *testing.T) { }, wantLen: 1, }, + { + name: "interval with only multiplier kept", + intervals: []PricingInterval{ + {MinTokens: 272000, InputMultiplier: testPtrFloat64(2)}, + }, + wantLen: 1, + }, { name: "mixed valid and invalid", intervals: []PricingInterval{ diff --git a/backend/internal/service/models_list_response_limit.go b/backend/internal/service/models_list_response_limit.go new file mode 100644 index 0000000000..b10260fd98 --- /dev/null +++ b/backend/internal/service/models_list_response_limit.go @@ -0,0 +1,10 @@ +package service + +import "github.com/Wei-Shaw/sub2api/internal/config" + +func resolveModelsListReadLimit(cfg *config.Config) int64 { + if cfg != nil && cfg.Gateway.ModelsListReadMaxBytes > 0 { + return cfg.Gateway.ModelsListReadMaxBytes + } + return config.DefaultModelsListReadMaxBytes +} diff --git a/backend/internal/service/models_list_response_limit_test.go b/backend/internal/service/models_list_response_limit_test.go new file mode 100644 index 0000000000..d697351510 --- /dev/null +++ b/backend/internal/service/models_list_response_limit_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestResolveModelsListReadLimit(t *testing.T) { + t.Run("nil config uses default", func(t *testing.T) { + require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(nil)) + }) + + t.Run("configured limit wins", func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.ModelsListReadMaxBytes = 16 << 20 + require.Equal(t, int64(16<<20), resolveModelsListReadLimit(cfg)) + }) + + t.Run("non-positive limit uses default", func(t *testing.T) { + cfg := &config.Config{} + require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(cfg)) + }) +} diff --git a/backend/internal/service/openai_access_state_failover_test.go b/backend/internal/service/openai_access_state_failover_test.go new file mode 100644 index 0000000000..a46f9ca571 --- /dev/null +++ b/backend/internal/service/openai_access_state_failover_test.go @@ -0,0 +1,424 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type openAIStream403AccountRepo struct { + AccountRepository + setErrorCalls int +} + +func (r *openAIStream403AccountRepo) SetError(context.Context, int64, string) error { + r.setErrorCalls++ + return nil +} + +type openAIAuthPolicyAccountRepo struct { + AccountRepository + tempCalls int + setErrorCalls int +} + +func (r *openAIAuthPolicyAccountRepo) SetTempUnschedulable(context.Context, int64, time.Time, string) error { + r.tempCalls++ + return nil +} + +func (r *openAIAuthPolicyAccountRepo) SetError(context.Context, int64, string) error { + r.setErrorCalls++ + return nil +} + +type openAIAuthPolicy403Counter struct { + counts []int64 +} + +func (s *openAIAuthPolicy403Counter) IncrementOpenAI403Count(context.Context, int64, int) (int64, error) { + if len(s.counts) == 0 { + return 1, nil + } + count := s.counts[0] + s.counts = s.counts[1:] + return count, nil +} + +func (*openAIAuthPolicy403Counter) ResetOpenAI403Count(context.Context, int64) error { + return nil +} + +func TestOpenAIUpstreamAccessStateClassification(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {"workspace_code", `{"detail":{"code":"deactivated_workspace"}}`, true}, + {"disabled_account_message", `{"error":{"message":"Your account is disabled"}}`, false}, + {"suspended_workspace_message", `{"response":{"error":{"message":"This workspace has been suspended"}}}`, false}, + {"deactivated_organization_message", `{"detail":{"message":"The organization is deactivated"}}`, false}, + {"scalar_detail", `{"detail":"This workspace has been disabled"}`, false}, + {"suspended_org_code", `{"error":{"code":"org_suspended"}}`, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(tt.body) + require.Equal(t, tt.want, isOpenAIUpstreamAccessStateError("", body)) + if !tt.want { + return + } + require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(http.StatusForbidden, "", body)) + require.True(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusForbidden, body)) + + err := newOpenAIUpstreamFailoverError(http.StatusForbidden, nil, body, "", true) + require.True(t, err.IsCredentialFailure()) + require.Equal(t, GatewayFailureScopeAccount, err.Scope) + require.Equal(t, OpenAIUpstreamAccessStateReason, err.Reason) + require.Equal(t, NextAccountRetry, err.NextAccountAction) + require.False(t, err.RetryableOnSameAccount) + require.False(t, err.RequestScopedTransient) + require.Equal(t, http.StatusBadGateway, err.ClientStatusCode) + require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, err.ClientMessage) + }) + } +} + +func TestOpenAIUpstreamAccessStateDoesNotScanEchoedJSON(t *testing.T) { + body := []byte(`{"error":{"code":"invalid_request_error","message":"Invalid input"},"echo":{"prompt":"my account is disabled"}}`) + require.False(t, isOpenAIUpstreamAccessStateError("", body)) + require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadRequest, body)) +} + +func TestOpenAIHTTPAccessStateDoesNotTrustBadRequestMessage(t *testing.T) { + body := []byte(`{"error":{"type":"invalid_request_error","code":"unknown_parameter","message":"Unknown parameter: account disabled"}}`) + svc := &OpenAIGatewayService{} + + require.False(t, isOpenAIUpstreamAccessStateError("", body), "free-form stream messages are not durable account evidence") + require.False(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusBadRequest, "", body)) + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadRequest, "", body)) + require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadRequest, body)) + + err := newOpenAIUpstreamFailoverError(http.StatusBadRequest, nil, body, "", false) + require.False(t, err.IsCredentialFailure()) +} + +func TestOpenAIHTTPAccessStateBadRequestDoesNotDisableAccount(t *testing.T) { + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 925, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + body := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: account disabled"}}`) + + disabled := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body) + + require.False(t, disabled) + require.Zero(t, repo.setErrorCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIStreamEchoedAccessStateMessageDoesNotDisableOrFailover(t *testing.T) { + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 926, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + payload := []byte(`{"type":"response.failed","response":{"error":{"type":"invalid_request_error","code":"unknown_parameter","message":"Unknown parameter: account disabled"}}}`) + message := extractOpenAISSEErrorMessage(payload) + + require.False(t, isOpenAIUpstreamAccessStateError(message, payload)) + require.False(t, openAIStreamFailedEventShouldFailover(payload, message)) + status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, message, nil) + require.Equal(t, http.StatusBadGateway, status) + require.False(t, disabled) + require.Zero(t, repo.setErrorCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIHTTPAccessStateTrustsStructuredCode(t *testing.T) { + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 930, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + body := []byte(`{"error":{"code":"organization_deactivated","message":"request rejected"}}`) + + require.True(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusBadRequest, "", body)) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadRequest, "", body)) + require.True(t, svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body)) + require.Equal(t, 1, repo.setErrorCalls) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIHTTPAuthMessagesUseExistingStatusPolicies(t *testing.T) { + t.Run("oauth 401 remains recoverable", func(t *testing.T) { + repo := &openAIAuthPolicyAccountRepo{} + rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + account := &Account{ID: 931, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, + Credentials: map[string]any{"refresh_token": "refreshable"}} + body := []byte(`{"error":{"message":"account is disabled"}}`) + + require.False(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusUnauthorized, "", body)) + require.True(t, svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusUnauthorized, nil, body)) + require.Zero(t, repo.setErrorCalls) + require.Equal(t, 1, repo.tempCalls) + }) + + t.Run("403 uses counter cooldown", func(t *testing.T) { + repo := &openAIAuthPolicyAccountRepo{} + counter := &openAIAuthPolicy403Counter{counts: []int64{1}} + rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimits.openAI403CounterCache = counter + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + rateLimits.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 932, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + body := []byte(`{"error":{"message":"workspace has been suspended"}}`) + + require.False(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusForbidden, "", body)) + require.True(t, svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body)) + require.Zero(t, repo.setErrorCalls) + require.Equal(t, 1, repo.tempCalls) + }) +} + +func TestOpenAICyberPolicyWrapped5xxNeverFailsOver(t *testing.T) { + body := []byte(`{"error":{"code":"cyber_policy","message":"blocked"}}`) + svc := &OpenAIGatewayService{} + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "wrapped upstream failure", body)) + require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadGateway, body)) +} + +func TestOpenAICapacityFailoverCarriesSafeTerminalResponse(t *testing.T) { + message := "Our servers are currently overloaded. Please try again later." + body := []byte(`{"error":{"code":"server_is_overloaded","message":"` + message + `"}}`) + err := newOpenAIUpstreamFailoverError(http.StatusBadRequest, nil, body, message, false) + + require.True(t, err.IsOpenAICapacityShed()) + require.Equal(t, http.StatusServiceUnavailable, err.ClientStatusCode) + require.Equal(t, message, err.ClientMessage) + require.NotContains(t, err.ClientMessage, "server_is_overloaded") +} + +func TestOpenAIStreamSemanticStatusesPreservedAcrossTerminalShapes(t *testing.T) { + tests := []struct { + name string + body string + status int + wantFailover bool + }{ + {"unauthorized", `{"type":"error","error":{"type":"authentication_error","code":"invalid_api_key","message":"unauthorized"}}`, http.StatusUnauthorized, true}, + {"forbidden", `{"type":"response.failed","response":{"error":{"type":"permission_error","message":"forbidden"}}}`, http.StatusForbidden, false}, + {"rate_limit", `{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`, http.StatusTooManyRequests, true}, + {"overload_529", `{"type":"error","error":{"status_code":529,"code":"overloaded","message":"overloaded"}}`, 529, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []byte(tt.body) + message := extractOpenAISSEErrorMessage(payload) + require.Equal(t, tt.status, openAIStreamFailureStatus(payload, message)) + require.Equal(t, tt.wantFailover, openAIStreamErrorEventShouldFailover(payload, message)) + }) + } +} + +func TestOpenAIStreamBareErrorUsesSemanticFailover(t *testing.T) { + payload := []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`) + require.True(t, openAIStreamErrorEventShouldFailover(payload, "slow down")) +} + +func TestOpenAIStream403FailoverRequiresStructuredAccountCredentialSignal(t *testing.T) { + tests := []struct { + name string + payload string + want bool + }{ + { + name: "ordinary permission error", + payload: `{"type":"response.failed","response":{"error":{"type":"permission_error","code":"forbidden","message":"access denied for this request"}}}`, + }, + { + name: "explicit 403 request status", + payload: `{"type":"error","error":{"type":"permission_error","code":"forbidden","status_code":403,"message":"forbidden content"}}`, + }, + { + name: "structured access state", + payload: `{"type":"response.failed","response":{"error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`, + want: true, + }, + { + name: "explicit credential auth code", + payload: `{"type":"error","error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}`, + want: true, + }, + { + name: "explicit authentication type", + payload: `{"type":"error","error":{"type":"authentication_error","status_code":403,"message":"credential rejected"}}`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []byte(tt.payload) + message := extractOpenAISSEErrorMessage(payload) + require.Equal(t, http.StatusForbidden, openAIStreamFailureStatus(payload, message)) + require.Equal(t, tt.want, openAIStreamFailedEventShouldFailover(payload, message)) + require.Equal(t, tt.want, openAIStreamErrorEventShouldFailover(payload, message)) + }) + } +} + +func TestOpenAIStream403PostOutputAccountSideEffectsIgnoreRequestPermissionErrors(t *testing.T) { + repo := &openAIStream403AccountRepo{} + rateLimits := &RateLimitService{accountRepo: repo} + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + account := &Account{ID: 918, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + payload := []byte(`{"type":"error","error":{"type":"permission_error","code":"forbidden","status_code":403,"message":"access denied for this request"}}`) + + status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "access denied for this request", nil) + + require.Equal(t, http.StatusForbidden, status) + require.False(t, disabled) + require.Zero(t, repo.setErrorCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIStream403ExplicitCredentialAuthAppliesAccountSideEffects(t *testing.T) { + repo := &openAIStream403AccountRepo{} + rateLimits := &RateLimitService{accountRepo: repo} + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + account := &Account{ID: 917, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + payload := []byte(`{"type":"error","error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}`) + + status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "credential rejected", nil) + + require.Equal(t, http.StatusForbidden, status) + require.True(t, disabled) + require.Equal(t, 1, repo.setErrorCalls) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIWSStandaloneFailedStructured403AppliesAccountSideEffectsOnce(t *testing.T) { + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 923, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + failed := []byte(`{"type":"response.failed","response":{"error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}}`) + + require.True(t, svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, failed)) + require.Equal(t, 1, repo.setErrorCalls) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIWSPairedStructured403SideEffectsCanBeDeduplicated(t *testing.T) { + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 924, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + errorEvent := []byte(`{"type":"error","error":{"code":"workspace_suspended","status_code":403,"message":"workspace is suspended"}}`) + failedEvent := []byte(`{"type":"response.failed","response":{"error":{"code":"workspace_suspended","status_code":403,"message":"workspace is suspended"}}}`) + + applied := svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, errorEvent) + if !applied { + applied = svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, failedEvent) + } + + require.True(t, applied) + require.Equal(t, 1, repo.setErrorCalls) +} + +func TestOpenAIStreamAccessStateAppliesAccountHealthBeforeFailover(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 919, Platform: PlatformOpenAI, Type: AccountTypeSetupToken} + payload := []byte(`{"type":"response.failed","response":{"error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`) + + status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "workspace is suspended", nil) + + require.Equal(t, http.StatusForbidden, status) + require.True(t, disabled) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIStreamPairedFailureAppliesAccountSideEffectsOnce(t *testing.T) { + const upstream = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"status_code\":403,\"code\":\"workspace_suspended\",\"message\":\"workspace is suspended\"}}\n\n" + + "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"error\":{\"status_code\":403,\"code\":\"workspace_suspended\",\"message\":\"workspace is suspended\"}}}\n\n" + + t.Run("native", func(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + toolCorrector: NewCodexToolCorrector(), + rateLimitService: &RateLimitService{accountRepo: repo}, + } + account := &Account{ID: 921, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + recorder := newOpenAIResponseFlushRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstream)), + } + + result, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5") + + require.Error(t, err) + require.NotNil(t, result) + require.Equal(t, 1, repo.setErrorCalls) + }) + + t.Run("passthrough", func(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + rateLimitService: &RateLimitService{accountRepo: repo}, + } + account := &Account{ID: 922, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + writer := &passthroughFlushTestWriter{ + ResponseWriter: c.Writer, + recorder: recorder, + failAfterWrites: -1, + } + c.Writer = writer + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstream)), + } + + result, err := svc.handleStreamingResponsePassthrough( + context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5", + ) + + require.Error(t, err) + require.NotNil(t, result) + require.Equal(t, 1, repo.setErrorCalls) + }) +} + +func TestOpenAIStreamOAuthLike429GetsDeadlineWithoutImmediateRuntimeBlock(t *testing.T) { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + t.Run(accountType, func(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 920, Platform: PlatformOpenAI, Type: accountType} + payload := []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`) + status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "slow down", nil) + err := svc.newOpenAIAccountFailoverError(account, status, nil, payload, "slow down", disabled, false) + + require.Equal(t, http.StatusTooManyRequests, status) + require.False(t, disabled) + require.True(t, err.RetryableOnSameAccount) + require.False(t, err.SameAccountRetryDeadline.IsZero()) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + }) + } +} diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index ace8366a0e..1f402202ca 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -12,9 +12,12 @@ import ( const ( openAIAccountStateUpdateTimeout = 5 * time.Second openAIOAuth429FallbackCooldown = 5 * time.Second + openAIOAuth429RetryWindow = 2 * time.Minute + openAIOAuth429RetryDelay = 500 * time.Millisecond + openAIOAuth429MaxRetryDelay = 8 * time.Second + openAIOAuth429MaxAccountAttempts = 3 openAIStopSchedulingBridgeCooldown = 2 * time.Minute openAIOAuth429StormWindow = 10 * time.Second - openAIOAuth429StormThreshold = 20 openAIOAuth429StormMaxAccountSwitches = 1 ) @@ -25,6 +28,49 @@ type OpenAIOAuth429FailoverState struct { grokOAuth429FollowupPending bool } +type openAIOAuth429Disposition uint8 + +const ( + openAIOAuth429Transient openAIOAuth429Disposition = iota + openAIOAuth429Quota5h + openAIOAuth429Quota7d + openAIOAuth429QuotaReset +) + +// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。明确窗口达到 +// 100% 时以该窗口为准;没有 100% 标记但包含重置头时,沿用 v179 的兼容语义, +// 仍视为配额限流信号。 +func classifyOpenAIOAuth429(headers http.Header, responseBody []byte) (openAIOAuth429Disposition, *time.Time) { + if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil { + if normalized := snapshot.Normalize(); normalized != nil { + if normalized.Used7dPercent != nil && *normalized.Used7dPercent >= 100 { + if normalized.Reset7dSeconds != nil { + now := time.Now() + resetAt := now.Add(time.Duration(*normalized.Reset7dSeconds) * time.Second) + return openAIOAuth429Quota7d, &resetAt + } + return openAIOAuth429Quota7d, nil + } + if normalized.Used5hPercent != nil && *normalized.Used5hPercent >= 100 { + if normalized.Reset5hSeconds != nil { + now := time.Now() + resetAt := now.Add(time.Duration(*normalized.Reset5hSeconds) * time.Second) + return openAIOAuth429Quota5h, &resetAt + } + return openAIOAuth429Quota5h, nil + } + } + } + if resetAt := calculateOpenAI429ResetTime(headers); resetAt != nil { + return openAIOAuth429QuotaReset, resetAt + } + if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil { + resetAt := time.Unix(*resetUnix, 0) + return openAIOAuth429QuotaReset, &resetAt + } + return openAIOAuth429Transient, nil +} + func openAIAccountStateContext(ctx context.Context) (context.Context, context.CancelFunc) { base := context.Background() if ctx != nil { @@ -34,7 +80,7 @@ func openAIAccountStateContext(ctx context.Context) (context.Context, context.Ca } func isOpenAIOAuthAccount(account *Account) bool { - return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth + return account != nil && account.IsOpenAIOAuthLike() } func isGrokOAuthAccount(account *Account) bool { @@ -55,8 +101,26 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) } + // Capacity shedding describes this request, not account health. Keep the + // account schedulable while the request-local retry budget handles recovery. + if account != nil && account.Platform == PlatformOpenAI && isOpenAIRequestScopedCapacityShed("", responseBody) { + return false + } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() + if account != nil && account.Platform == PlatformOpenAI && isOpenAIHTTPUpstreamAccessStateError(statusCode, "", responseBody) { + message := "OpenAI upstream account or workspace is unavailable" + if upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(responseBody)); upstreamMsg != "" { + message = upstreamMsg + } + if s != nil && s.rateLimitService != nil { + s.rateLimitService.handleAuthError(stateCtx, account, message) + } + if s != nil { + s.BlockAccountScheduling(account, time.Time{}, "openai_access_state") + } + return true + } if account != nil && account.Platform == PlatformOpenAI && isOpenAIContextWindowError("", responseBody) { return false @@ -72,6 +136,10 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s == nil || account == nil { return false } + // Team 联动熔断必须先于 model-not-found 与账户级临时不可调度规则的早退。 + if s.rateLimitService != nil { + s.rateLimitService.maybeHandleOpenAITeamLinkedError(stateCtx, account, statusCode, responseBody) + } stateCtx = withTempUnschedulableModel(stateCtx, canonicalModel) if s.rateLimitService != nil && len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) { return true @@ -140,20 +208,101 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context return } s.recordOpenAIOAuth429() + disposition, resetAt := classifyOpenAIOAuth429(headers, responseBody) + if disposition == openAIOAuth429Transient && s.openAIOAuth429RetryWindowActive(account) { + return + } cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) - if s.rateLimitService != nil { - if resetAt := s.rateLimitService.calculateOpenAI429ResetTime(headers); resetAt != nil && resetAt.After(time.Now()) { - cooldownUntil = *resetAt - } else if resetUnix := parseOpenAIRateLimitResetTime(responseBody); resetUnix != nil { - if resetAt := time.Unix(*resetUnix, 0); resetAt.After(time.Now()) { - cooldownUntil = resetAt - } - } else if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { + if resetAt != nil && resetAt.After(time.Now()) { + cooldownUntil = *resetAt + } else if s.rateLimitService != nil { + if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { cooldownUntil = time.Now().Add(cooldown) } } s.BlockAccountScheduling(account, cooldownUntil, "429") + s.openaiOAuth429RetryStartedAt.Delete(account.ID) +} + +func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *Account, statusCode int, shouldDisable bool) bool { + return s.shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account, statusCode, shouldDisable, nil, nil) +} + +func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account *Account, statusCode int, shouldDisable bool, headers http.Header, responseBody []byte) bool { + if shouldDisable || statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) || account.IsShadow() { + return false + } + disposition, _ := classifyOpenAIOAuth429(headers, responseBody) + if disposition != openAIOAuth429Transient { + return false + } + // markOpenAIOAuth429RateLimited parks the account once the window expires. + // Do not accidentally create a fresh window after that transition. + if s.isOpenAIAccountRuntimeBlocked(account) { + return false + } + return s.openAIOAuth429RetryWindowActive(account) +} + +// ShouldRetryOpenAIOAuth429 lets RateLimitService defer persistent account +// cooldown until the gateway's same-account retry window is exhausted. +func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, headers http.Header, responseBody []byte) bool { + if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() || s.isOpenAIAccountRuntimeBlocked(account) { + return false + } + disposition, _ := classifyOpenAIOAuth429(headers, responseBody) + if disposition != openAIOAuth429Transient { + return false + } + return s.openAIOAuth429RetryWindowActive(account) +} + +func (s *OpenAIGatewayService) openAIOAuth429RetryWindowActive(account *Account) bool { + if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { + return false + } + now := time.Now() + value, _ := s.openaiOAuth429RetryStartedAt.LoadOrStore(account.ID, now) + startedAt, ok := value.(time.Time) + if !ok { + s.openaiOAuth429RetryStartedAt.Store(account.ID, now) + startedAt = now + } + return now.Before(startedAt.Add(openAIOAuth429RetryWindow)) +} + +func (s *OpenAIGatewayService) openAIOAuth429RetryDeadline(account *Account) time.Time { + if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() { + return time.Time{} + } + value, ok := s.openaiOAuth429RetryStartedAt.Load(account.ID) + if !ok { + return time.Time{} + } + startedAt, ok := value.(time.Time) + if !ok { + return time.Time{} + } + return startedAt.Add(openAIOAuth429RetryWindow) +} + +func openAIOAuth429SameAccountRetryDelay(headers http.Header, deadline time.Time) time.Duration { + delay := openAIOAuth429RetryDelay + now := time.Now() + if resetAt := parseRetryAfterResetTime(headers, now); resetAt != nil && resetAt.After(now) { + delay = resetAt.Sub(now) + } + if delay > openAIOAuth429MaxRetryDelay { + delay = openAIOAuth429MaxRetryDelay + } + if remaining := time.Until(deadline); !deadline.IsZero() && delay > remaining { + delay = remaining + } + if delay < 0 { + return 0 + } + return delay } func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) { @@ -219,6 +368,7 @@ func (s *OpenAIGatewayService) ClearAccountSchedulingBlock(accountID int64) { mu.Lock() defer mu.Unlock() s.openaiAccountRuntimeBlockUntil.Delete(accountID) + s.openaiOAuth429RetryStartedAt.Delete(accountID) s.openaiAccountRuntimeBlockGeneration.Store(accountID, s.openaiAccountRuntimeBlockSequence.Add(1)) } @@ -264,6 +414,9 @@ func canonicalOpenAIAccountSchedulingModel(account *Account, requestedModel stri if account == nil || model == "" { return model } + if account.IsOpenAI() { + return resolveOpenAIAccountUpstreamModelForRequest(account, model, false) + } if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" { return mapped } @@ -324,17 +477,6 @@ func (s *OpenAIGatewayService) recordOpenAIOAuth429() { s.openaiOAuth429WindowCount.Add(1) } -func (s *OpenAIGatewayService) isOpenAIOAuth429Storm() bool { - if s == nil { - return false - } - windowStart := s.openaiOAuth429WindowStartUnixNano.Load() - if windowStart == 0 || time.Since(time.Unix(0, windowStart)) >= openAIOAuth429StormWindow { - return false - } - return s.openaiOAuth429WindowCount.Load() >= openAIOAuth429StormThreshold -} - func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account, statusCode int, failedSwitches int, state *OpenAIOAuth429FailoverState) bool { if failedSwitches < openAIOAuth429StormMaxAccountSwitches { return false @@ -359,5 +501,8 @@ func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account if statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) { return false } - return s.isOpenAIOAuth429Storm() + // Each OpenAI OAuth candidate has already consumed its full same-account + // retry window before reaching this switch point. A global storm is useful + // telemetry, but must not prevent trying the bounded next-account budget. + return failedSwitches >= openAIOAuth429MaxAccountAttempts } diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 09d4662468..89d8c5b5ec 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -13,18 +13,164 @@ import ( "github.com/stretchr/testify/require" ) -func TestOpenAI429FastPath_MarksOAuthAccountCoolingDown(t *testing.T) { - svc := &OpenAIGatewayService{} +type oauth429RateLimitRepo struct { + mockAccountRepoForGemini + setRateLimitedCalls int + lastRateLimitedUntil time.Time +} + +func (r *oauth429RateLimitRepo) SetRateLimited(_ context.Context, _ int64, until time.Time) error { + r.setRateLimitedCalls++ + r.lastRateLimitedUntil = until + return nil +} + +func TestOpenAI429FastPath_KeepsOAuthAccountSchedulableDuringRetryWindow(t *testing.T) { + repo := &oauth429RateLimitRepo{} + rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + rateLimits.SetAccountRuntimeBlocker(svc) account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth} apiKeyAccount := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + setupTokenAccount := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeSetupToken} + grokOAuthAccount := &Account{ID: 45, Platform: PlatformGrok, Type: AccountTypeOAuth} shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, nil) apiKeyShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), apiKeyAccount, http.StatusTooManyRequests, http.Header{}, nil) require.False(t, shouldDisable) require.False(t, apiKeyShouldDisable) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount), "API-key 429 keeps the existing scheduler cooldown behavior") + require.Equal(t, 1, repo.setRateLimitedCalls, "only the API-key 429 should persist a scheduler block") + require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) + require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(setupTokenAccount, http.StatusTooManyRequests, false)) + require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(apiKeyAccount, http.StatusTooManyRequests, false)) + require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(grokOAuthAccount, http.StatusTooManyRequests, false)) + require.WithinDuration(t, time.Now().Add(openAIOAuth429RetryWindow), svc.openAIOAuth429RetryDeadline(account), time.Second) + require.WithinDuration(t, time.Now().Add(openAIOAuth429RetryWindow), svc.openAIOAuth429RetryDeadline(setupTokenAccount), time.Second) +} + +func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 420, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + + svc.markOpenAIOAuth429RateLimited(context.Background(), account, http.Header{}, nil) + + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) +} + +func TestOpenAI429FastPath_BlocksOAuthImmediatelyWhenSevenDayQuotaIsExhausted(t *testing.T) { + repo := &oauth429RateLimitRepo{} + rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + rateLimits.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 423, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + headers.Set("x-codex-secondary-used-percent", "20") + headers.Set("x-codex-secondary-reset-after-seconds", "3600") + headers.Set("x-codex-secondary-window-minutes", "300") + + shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded"}}`)) + + require.False(t, shouldDisable) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.Equal(t, 1, repo.setRateLimitedCalls) + require.Greater(t, time.Until(repo.lastRateLimitedUntil), 6*24*time.Hour) + require.False(t, svc.ShouldRetryOpenAIOAuth429(account, headers, nil)) +} + +func TestOpenAI429FastPath_RetriesOAuthWhenNoQuotaSignalExists(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 424, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + headers := http.Header{"Retry-After": []string{"1"}} + + require.True(t, svc.ShouldRetryOpenAIOAuth429(account, headers, []byte(`{"error":{"type":"rate_limit_error","message":"try again"}}`))) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestOpenAIStream429IgnoresSuccessfulQuotaSnapshotHeaders(t *testing.T) { + repo := &oauth429RateLimitRepo{} + rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimits} + rateLimits.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 421, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + payload := []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`) + + status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "slow down", headers) + + require.Equal(t, http.StatusTooManyRequests, status) + require.False(t, disabled) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + value, ok := svc.openaiAccountRuntimeBlockUntil.Load(account.ID) + require.True(t, ok) + blockedUntil, ok := value.(time.Time) + require.True(t, ok) + require.Less(t, time.Until(blockedUntil), time.Minute, "stream 429 must not inherit the normal seven-day quota snapshot") + if !repo.lastRateLimitedUntil.IsZero() { + require.Less(t, time.Until(repo.lastRateLimitedUntil), time.Minute) + } +} + +func TestOpenAIHTTP429StillUsesQuotaResetHeaders(t *testing.T) { + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{}} + account := &Account{ID: 422, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + + svc.markOpenAIOAuth429RateLimited(context.Background(), account, headers, nil) + + value, ok := svc.openaiAccountRuntimeBlockUntil.Load(account.ID) + require.True(t, ok) + blockedUntil, ok := value.(time.Time) + require.True(t, ok) + require.Greater(t, time.Until(blockedUntil), 6*24*time.Hour, "real HTTP 429 must retain the upstream quota reset") +} + +func TestOpenAI429RetryDelayHonorsBoundedRetryAfter(t *testing.T) { + deadline := time.Now().Add(openAIOAuth429RetryWindow) + require.Equal(t, openAIOAuth429RetryDelay, openAIOAuth429SameAccountRetryDelay(nil, deadline)) + require.Equal(t, openAIOAuth429MaxRetryDelay, openAIOAuth429SameAccountRetryDelay(http.Header{"Retry-After": []string{"90"}}, deadline)) +} + +func TestOpenAI429FastPath_OpenCodeGoUsageLimitUsesMessageResetDuration(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_test/go"},"metadata":{"workspace":"wrk_test","limitName":"5 hour"}}`) + + before := time.Now() + shouldDisable := svc.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusTooManyRequests, + http.Header{}, + body, + ) + after := time.Now() + + require.False(t, shouldDisable) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, account.ID, repo.lastRateLimitID) + expectedResetAfter := 4*time.Hour + 59*time.Minute + require.False(t, repo.lastRateLimitReset.Before(before.Add(expectedResetAfter-time.Second))) + require.False(t, repo.lastRateLimitReset.After(after.Add(expectedResetAfter))) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) - require.False(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount)) } // TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429, @@ -50,7 +196,7 @@ func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) { svc.markOpenAIOAuth429RateLimited(context.Background(), normal, headers, nil) require.False(t, svc.isOpenAIAccountRuntimeBlocked(shadow), "spark shadow must not be runtime-blocked by /responses global 429") - require.True(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account should still be runtime-blocked") + require.True(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account with an exhausted 5h window must be paused") } func TestOpenAIRuntimeBlock_AppliesToOpenAIAPIKeyWhenRateLimitServiceStopsScheduling(t *testing.T) { @@ -347,7 +493,8 @@ func TestOpenAIOAuth429_NonmatchingModelTempRuleKeepsAccountRuntimeBlock(t *test ) require.False(t, shouldDisable) - require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) require.Empty(t, repo.modelRateLimitCalls) } @@ -398,7 +545,7 @@ func TestOpenAIRuntimeBlock_ClearAccountSchedulingBlock(t *testing.T) { require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } -func TestShouldStopOpenAIOAuth429Failover_OnlyDuringStorm(t *testing.T) { +func TestShouldStopOpenAIOAuth429Failover_AfterBoundedFullWindows(t *testing.T) { svc := &OpenAIGatewayService{} account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth} apiKeyAccount := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} @@ -406,11 +553,9 @@ func TestShouldStopOpenAIOAuth429Failover_OnlyDuringStorm(t *testing.T) { require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state)) - for i := 0; i < openAIOAuth429StormThreshold; i++ { - svc.recordOpenAIOAuth429() - } - - require.True(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state)) + require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state)) + require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 2, &state)) + require.True(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 3, &state)) require.False(t, svc.ShouldStopOpenAIOAuth429Failover(apiKeyAccount, http.StatusTooManyRequests, 1, &state)) require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusInternalServerError, 1, &state)) require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 0, &state)) diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index 140e15d490..4ba1f16a55 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -85,7 +85,7 @@ func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T) } require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias")) - svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil) + svc.ReportOpenAIAccountScheduleResult(account, canonicalModel, true, nil) require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias")) } diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 6457ef8fc2..2986f6abeb 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -21,6 +21,7 @@ import ( const ( openAIAccountScheduleLayerPreviousResponse = "previous_response_id" + openAIAccountScheduleLayerGuardianParent = "guardian_parent" openAIAccountScheduleLayerSessionSticky = "session_hash" openAIAccountScheduleLayerLoadBalance = "load_balance" openAIAdvancedSchedulerSettingKey = "openai_advanced_scheduler_enabled" @@ -71,10 +72,12 @@ type OpenAIAccountScheduleRequest struct { Platform string SessionHash string StickyAccountID int64 + GuardianParentAccountID int64 StickyPreviousAccountID int64 StickyWeighted bool SubscriptionPriority bool PreserveStickyBinding bool + RequirePrivacySet bool PreviousResponseID string PreviousResponseCanMove bool UseUpstreamTokenCost bool @@ -82,8 +85,10 @@ type OpenAIAccountScheduleRequest struct { RequiredTransport OpenAIUpstreamTransport RequiredCapability OpenAIEndpointCapability RequiredImageCapability OpenAIImagesCapability - RequireCompact bool - ExcludedIDs map[int64]struct{} + // RequireCompact is only for legacy /responses/compact capability filtering + // and compact_model_mapping; native remote compaction v2 leaves it false. + RequireCompact bool + ExcludedIDs map[int64]struct{} } type OpenAIAccountScheduleDecision struct { @@ -371,6 +376,9 @@ func (s *defaultOpenAIAccountScheduler) Select( ctx context.Context, req OpenAIAccountScheduleRequest, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + if s != nil && s.service != nil && s.service.openAIGroupRequiresPrivacySet(ctx, req.GroupID) { + req.RequirePrivacySet = true + } decision := OpenAIAccountScheduleDecision{} start := time.Now() defer func() { @@ -379,7 +387,7 @@ func (s *defaultOpenAIAccountScheduler) Select( }() previousResponseID := strings.TrimSpace(req.PreviousResponseID) - if previousResponseID != "" && normalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI && + if previousResponseID != "" && NormalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI && (!req.StickyWeighted || !req.PreviousResponseCanMove) { selection, err := s.service.selectAccountByPreviousResponseIDForCapability( ctx, @@ -394,7 +402,14 @@ func (s *defaultOpenAIAccountScheduler) Select( return nil, decision, err } if selection != nil && selection.Account != nil { - if !s.isAccountTransportCompatible(selection.Account, req.RequiredTransport) { + compatible, _ := s.isAccountRequestCompatibleReason(ctx, selection.Account, req) + hasGroupMetadata := len(selection.Account.GroupIDs) > 0 || len(selection.Account.AccountGroups) > 0 + groupCompatible := !hasGroupMetadata || openAIStickyAccountMatchesGroup(selection.Account, req.GroupID) + if hasGroupMetadata && s.service != nil { + groupCompatible = s.service.openAIAccountMatchesSchedulingGroup(selection.Account, req.GroupID) + } + if !groupCompatible || + !compatible || !s.isAccountTransportCompatible(selection.Account, req.RequiredTransport) { if selection.ReleaseFunc != nil { selection.ReleaseFunc() } @@ -413,6 +428,23 @@ func (s *defaultOpenAIAccountScheduler) Select( } } + if req.GuardianParentAccountID > 0 { + parentReq := req + parentReq.StickyAccountID = req.GuardianParentAccountID + parentReq.PreserveStickyBinding = true + selection, _, err := s.selectBySessionHash(ctx, parentReq) + if err != nil { + return nil, decision, err + } + if selection != nil && selection.Account != nil { + decision.Layer = openAIAccountScheduleLayerGuardianParent + decision.StickySessionHit = true + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + return selection, decision, nil + } + } + if !req.StickyWeighted { selection, escapedSticky, err := s.selectBySessionHash(ctx, req) if err != nil { @@ -463,6 +495,11 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( } accountID := req.StickyAccountID + clearBinding := func() { + if !req.PreserveStickyBinding { + _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + } + } if accountID <= 0 { var err error accountID, err = s.service.getStickySessionAccountID(ctx, req.GroupID, sessionHash) @@ -481,40 +518,40 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( account, err := s.service.getSchedulableAccount(ctx, accountID) if err != nil || account == nil { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + clearBinding() return nil, false, nil } - if shouldClearStickySession(account, req.RequestedModel) || account.Platform != normalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() || !account.IsSchedulable() { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + if shouldClearStickySession(account, req.RequestedModel) || account.Platform != NormalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() || !account.IsSchedulable() { + clearBinding() return nil, false, nil } if !s.isAccountRequestCompatible(ctx, account, req) { return nil, false, nil } if !s.isAccountTransportCompatible(account, req.RequiredTransport) { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + clearBinding() return nil, false, nil } account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.GroupID, req.Platform, req.RequestedModel, req.RequireCompact, req.RequiredCapability) - if account == nil || !s.service.openAIAccountMatchesSchedulingGroup(account, req.GroupID) || !s.isAccountTransportCompatible(account, req.RequiredTransport) { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + if account == nil || !s.service.openAIAccountMatchesSchedulingGroup(account, req.GroupID) || !s.isAccountRequestCompatible(ctx, account, req) || !s.isAccountTransportCompatible(account, req.RequiredTransport) { + clearBinding() return nil, false, nil } // Free-tier soft gate: sticky session must not pin an over-quota free OAuth account. // Admin QueryQuota / import probes do not use this path. if account != nil && len(s.filterGrokFreeQuotaAccounts(ctx, []Account{*account})) == 0 { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + clearBinding() return nil, false, nil } // Team+model cool: sticky must not pin a sibling under the same team 429 window. now := time.Now() upstreamModel := canonicalOpenAIAccountSchedulingModel(account, req.RequestedModel) if account != nil && isGrokTeamModelRateLimited(account, upstreamModel, now) { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + clearBinding() return nil, false, nil } if account != nil && isGrokModelQuotaBlocked(account.ID, upstreamModel, now) { - _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + clearBinding() return nil, false, nil } escapeCfg := s.service.openAIStickyEscapeConfig() @@ -529,7 +566,9 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( } result, acquireErr := s.service.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if acquireErr == nil && result != nil && result.Acquired { - _ = s.service.refreshStickySessionTTL(ctx, req.GroupID, sessionHash, s.service.openAIWSSessionStickyTTL()) + if !req.PreserveStickyBinding { + _ = s.service.refreshStickySessionTTL(ctx, req.GroupID, sessionHash, s.service.openAIWSSessionStickyTTL()) + } return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, Acquired: true, @@ -1404,7 +1443,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( filterStats.exclude("not_schedulable") continue } - if account.Platform != normalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() { + if account.Platform != NormalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() { filterStats.exclude("platform_mismatch") continue } @@ -1412,11 +1451,10 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( filterStats.exclude("runtime_blocked") continue } - // require_privacy_set: 跳过 privacy 未设置的账号并标记异常 + // require_privacy_set is a group-scoped eligibility gate. Do not mutate the + // shared account: another group may intentionally allow accounts whose + // upstream privacy setting has not been confirmed. if schedGroup != nil && schedGroup.RequirePrivacySet && !account.IsPrivacySet() { - s.service.BlockAccountScheduling(account, time.Time{}, "privacy_not_set") - _ = s.service.accountRepo.SetError(ctx, account.ID, - fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name)) filterStats.exclude("privacy_not_set") continue } @@ -1728,6 +1766,9 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if account == nil { return false, "account_nil" } + if req.RequirePrivacySet && !account.IsPrivacySet() { + return false, "privacy_not_set" + } if s != nil && s.service != nil && s.service.isOpenAIAccountRequestRuntimeBlocked(account, req.RequestedModel) { return false, "runtime_blocked" } @@ -1739,6 +1780,9 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con // rechecks won't reach healthy accounts that fell outside TopK — manifesting as // "no available accounts" even though healthy ones exist. if paused, decision := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { + if decision.reason != "" { + return false, decision.reason + } reason := "quota_auto_pause" if decision.window != "" { reason += "_" + decision.window @@ -2123,7 +2167,7 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( return selection, decision, err } // The circuit only ever quarantines PlatformOpenAI accounts. - if normalizeOpenAICompatiblePlatform(platform) != PlatformOpenAI { + if NormalizeOpenAICompatiblePlatform(platform) != PlatformOpenAI { return selection, decision, err } blocked := s.getOpenAIProxyStreamCircuit().activeBlockCount(time.Now()) @@ -2134,6 +2178,38 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( return s.selectAccountWithSchedulerOnce(withOpenAIProxyStreamQuarantineBypass(ctx), groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, requiredImageCapability, requireCompact, platform, previousResponseCanMove, useUpstreamTokenCost) } +type openAIGroupPrivacyRequirementContextKey struct{} + +type openAIGroupPrivacyRequirement struct { + groupID int64 + required bool +} + +func (s *OpenAIGatewayService) withOpenAIGroupPrivacyRequirement(ctx context.Context, groupID *int64) context.Context { + return context.WithValue(ctx, openAIGroupPrivacyRequirementContextKey{}, openAIGroupPrivacyRequirement{ + groupID: derefGroupID(groupID), + required: s.loadOpenAIGroupRequiresPrivacySet(ctx, groupID), + }) +} + +func (s *OpenAIGatewayService) openAIGroupRequiresPrivacySet(ctx context.Context, groupID *int64) bool { + if cached, ok := ctx.Value(openAIGroupPrivacyRequirementContextKey{}).(openAIGroupPrivacyRequirement); ok && cached.groupID == derefGroupID(groupID) { + return cached.required + } + return s.loadOpenAIGroupRequiresPrivacySet(ctx, groupID) +} + +func (s *OpenAIGatewayService) loadOpenAIGroupRequiresPrivacySet(ctx context.Context, groupID *int64) bool { + if s == nil || groupID == nil || s.schedulerSnapshot == nil { + return false + } + group, err := s.schedulerSnapshot.GetGroupByID(ctx, *groupID) + if err != nil { + return true + } + return group != nil && group.RequirePrivacySet +} + func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( ctx context.Context, groupID *int64, @@ -2150,26 +2226,64 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( useUpstreamTokenCost bool, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + ctx = s.withOpenAIGroupPrivacyRequirement(ctx, groupID) // 分组利润控制:唯一文本调度入口的防御性装门。handler 文本 // 入口已在请求开始经 WithOpenAIRequestPricingContext 装门并固定 pricingAt, // 此处对同分组门直接复用(failover 重入阈值稳定),仅为不经 handler 装配的 // 内部调用兜底。图片/视频调度不在利润门范围:requiredImageCapability 非空的 - // Images 调度不装门;requiredCapability == OpenAIEndpointCapabilityResponses - // 当前仅显式生图意图的 /v1/responses 设置(HTTP openAIResponsesRequiredCapability - // 与 WS 桥同款判定),同样不装门——若未来把该 capability 用于非生图流量, - // 需要同步收窄本条件(有测试钉死该映射)。 - if requiredImageCapability == "" && requiredCapability != OpenAIEndpointCapabilityResponses { + // Images 调度不装门;其他使用 Responses 能力的文本请求(包括原生远程压缩) + // 仍须装门。其余媒体路径通过 WithOpenAIProfitControlSuppressed 显式跳过。 + if requiredImageCapability == "" { ctx = s.withOpenAIProfitControlGate(ctx, groupID) } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) decision := OpenAIAccountScheduleDecision{} + preserveGuardianParentBinding := preserveOpenAIGuardianParentBinding(ctx, sessionHash) + guardianParentAccountID := int64(0) + if strings.TrimSpace(previousResponseID) == "" { + guardianParentAccountID = s.resolveOpenAIGuardianParentAccountID(ctx, groupID) + } scheduler := s.getOpenAIAccountScheduler(ctx) if scheduler == nil { decision.Layer = openAIAccountScheduleLayerLoadBalance + if guardianParentAccountID > 0 { + if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { + return nil, decision, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) + } + fallbackScheduler := &defaultOpenAIAccountScheduler{service: s, stats: newOpenAIAccountRuntimeStats()} + selection, _, err := fallbackScheduler.selectBySessionHash(ctx, OpenAIAccountScheduleRequest{ + GroupID: groupID, + Platform: platform, + SessionHash: sessionHash, + StickyAccountID: guardianParentAccountID, + PreserveStickyBinding: true, + RequestedModel: requestedModel, + RequiredTransport: requiredTransport, + RequiredCapability: requiredCapability, + RequiredImageCapability: requiredImageCapability, + RequireCompact: requireCompact, + ExcludedIDs: excludedIDs, + RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID), + }) + if err != nil { + return nil, decision, err + } + if selection != nil && selection.Account != nil { + decision.Layer = openAIAccountScheduleLayerGuardianParent + decision.StickySessionHit = true + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + return selection, decision, nil + } + } + legacySessionHash := sessionHash + if preserveGuardianParentBinding { + legacySessionHash = "" + } if requiredTransport == OpenAIUpstreamTransportAny || requiredTransport == OpenAIUpstreamTransportHTTPSSE { effectiveExcludedIDs := cloneExcludedAccountIDs(excludedIDs) for { - selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, platform, sessionHash, requestedModel, effectiveExcludedIDs, requireCompact, requiredCapability, useUpstreamTokenCost) + selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, platform, legacySessionHash, requestedModel, effectiveExcludedIDs, requireCompact, requiredCapability, useUpstreamTokenCost) if err != nil { return nil, decision, err } @@ -2194,7 +2308,7 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( effectiveExcludedIDs := cloneExcludedAccountIDs(excludedIDs) for { - selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, platform, sessionHash, requestedModel, effectiveExcludedIDs, requireCompact, requiredCapability, useUpstreamTokenCost) + selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, platform, legacySessionHash, requestedModel, effectiveExcludedIDs, requireCompact, requiredCapability, useUpstreamTokenCost) if err != nil { return nil, decision, err } @@ -2243,9 +2357,12 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( Platform: platform, SessionHash: sessionHash, StickyAccountID: stickyAccountID, + GuardianParentAccountID: guardianParentAccountID, StickyPreviousAccountID: stickyPreviousAccountID, StickyWeighted: stickyWeighted, SubscriptionPriority: subscriptionPriority, + PreserveStickyBinding: preserveGuardianParentBinding, + RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID), PreviousResponseID: previousResponseID, PreviousResponseCanMove: previousResponseCanMove, UseUpstreamTokenCost: useUpstreamTokenCost, @@ -2299,15 +2416,38 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport } -func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) { +func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(account *Account, model string, success bool, firstTokenMs *int, observedErr ...error) bool { + if account == nil { + return false + } + accountID := account.ID + healthTripped := false + if s != nil && s.rateLimitService != nil { + if success { + s.rateLimitService.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), account) + } else if len(observedErr) > 0 && observedErr[0] != nil { + healthTripped = s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, observedErr[0]) + } + } if success { + s.openaiOAuth429RetryStartedAt.Delete(accountID) s.clearOpenAIAccountModelTransientState(accountID, normalizeOpenAIAccountModelTransientModel(model)) } scheduler := s.getOpenAIAccountScheduler(context.Background()) if scheduler == nil { - return + return healthTripped } scheduler.ReportResult(accountID, success, firstTokenMs) + return healthTripped +} + +// ObserveOpenAIAccountHealthFailure records failures that cannot reach the +// scheduler-result path, for example after semantic response bytes were sent. +func (s *OpenAIGatewayService) ObserveOpenAIAccountHealthFailure(ctx context.Context, account *Account, observedErr error) bool { + if s == nil || s.rateLimitService == nil || account == nil || observedErr == nil { + return false + } + return s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(ctx, account, observedErr) } func (s *OpenAIGatewayService) RecordOpenAIAccountSwitch() { @@ -2663,7 +2803,7 @@ func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthScheduli continue } factors[account.ID] = openAIUpstreamCostNeutralFactor - if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() { + if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike() { continue } eligibleCount++ @@ -2727,7 +2867,7 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS // 与 openAIUpstreamCostFactors 使用同一道平台门控:只有 OpenAI 平台账号 // 的倍率参与 legacy 低倍率优先排序。上游自报倍率来自中转方,不能让它对 // 其他平台的调度产生影响——否则自报低价即可吸走流量,而实际结算走本地倍率。 - if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() { + if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike() { continue } rate, ok := openAISchedulingRate(account, now, oauthSchedulingRateMultiplier) @@ -2745,7 +2885,7 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS } func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier float64) (float64, bool) { - if account != nil && account.IsOpenAIOAuth() { + if account != nil && account.IsOpenAIOAuthLike() { return oauthSchedulingRateMultiplier, true } return openAIFreshUpstreamBillingRate(account, now) diff --git a/backend/internal/service/openai_account_scheduler_compact_test.go b/backend/internal/service/openai_account_scheduler_compact_test.go index b987600389..87a5b22811 100644 --- a/backend/internal/service/openai_account_scheduler_compact_test.go +++ b/backend/internal/service/openai_account_scheduler_compact_test.go @@ -116,6 +116,262 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_CompactRejectsExplicitl require.Nil(t, selection) } +func newOpenAICompactionSchedulerTestService(accounts []Account, advanced bool) *OpenAIGatewayService { + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + if advanced { + svc.rateLimitService = newOpenAIAdvancedSchedulerRateLimitService("true") + } + return svc +} + +func selectOpenAICompactionSchedulerTestAccount(t *testing.T, svc *OpenAIGatewayService, groupID int64, requireCompact bool) (*AccountSelectionResult, error) { + t.Helper() + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + context.Background(), + &groupID, + "", + "", + "gpt-5.6-sol", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityResponses, + requireCompact, + false, + false, + ) + return selection, err +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_NativeCompactionIgnoresLegacyCompactProbe(t *testing.T) { + for _, advanced := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy_scheduler", true: "advanced_scheduler"}[advanced], func(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + svc := newOpenAICompactionSchedulerTestService([]Account{{ + ID: 71012, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_compact_supported": false, + "openai_responses_supported": true, + }, + }}, advanced) + + selection, err := selectOpenAICompactionSchedulerTestAccount(t, svc, 91007, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, int64(71012), selection.Account.ID) + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_NativeCompactionAllowsForceOff(t *testing.T) { + for _, advanced := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy_scheduler", true: "advanced_scheduler"}[advanced], func(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + svc := newOpenAICompactionSchedulerTestService([]Account{{ + ID: 71013, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_compact_mode": OpenAICompactModeForceOff, + "openai_responses_supported": true, + }, + }}, advanced) + + selection, err := selectOpenAICompactionSchedulerTestAccount(t, svc, 91008, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, int64(71013), selection.Account.ID) + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_NativeCompactionRequiresResponsesCapability(t *testing.T) { + for _, advanced := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy_scheduler", true: "advanced_scheduler"}[advanced], func(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + svc := newOpenAICompactionSchedulerTestService([]Account{{ + ID: 71014, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_compact_mode": OpenAICompactModeForceOn, + "openai_responses_supported": false, + }, + }}, advanced) + + selection, err := selectOpenAICompactionSchedulerTestAccount(t, svc, 91009, false) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.NotErrorIs(t, err, ErrNoAvailableCompactAccounts) + require.NotContains(t, err.Error(), "/responses/compact") + require.Nil(t, selection) + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_LegacyCompactionKeepsCompactEligibility(t *testing.T) { + for _, advanced := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy_scheduler", true: "advanced_scheduler"}[advanced], func(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + svc := newOpenAICompactionSchedulerTestService([]Account{ + { + ID: 71015, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_compact_supported": false, + "openai_responses_supported": true, + }, + }, + { + ID: 71016, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_compact_mode": OpenAICompactModeForceOff, + "openai_responses_supported": true, + }, + }, + }, advanced) + + selection, err := selectOpenAICompactionSchedulerTestAccount(t, svc, 91010, true) + require.ErrorIs(t, err, ErrNoAvailableCompactAccounts) + require.Contains(t, err.Error(), "/responses/compact") + require.Nil(t, selection) + }) + } +} + +// TestOpenAIGatewayService_SelectAccountWithScheduler_CompactRequiresResponsesCapability +// prevents a confirmed Chat Completions-only API key from silently dropping the +// compaction trigger through the Responses-to-Chat fallback. +func TestOpenAIGatewayService_SelectAccountWithScheduler_CompactRequiresResponsesCapability(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(91005) + accounts := []Account{{ + ID: 71050, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_compact_supported": true, + "openai_responses_supported": false, + }, + }} + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "", + "", + "gpt-5.4", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityResponses, + true, + false, + false, + ) + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoAvailableAccounts)) + require.Nil(t, selection) +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_CompactSkipsChatOnlyAccount(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(91006) + accounts := []Account{ + { + ID: 71060, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + Extra: map[string]any{ + "openai_compact_supported": true, + "openai_responses_supported": false, + }, + }, + { + ID: 71061, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + Extra: map[string]any{ + "openai_compact_supported": true, + "openai_responses_supported": true, + }, + }, + } + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "", + "", + "gpt-5.6-sol", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityResponses, + true, + false, + false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(71061), selection.Account.ID) +} + // TestOpenAIGatewayService_SelectAccountWithScheduler_CompactFallsBackToUnknown // 验证当没有"已知支持"账号时,compact 请求会回退到"未探测"账号。 func TestOpenAIGatewayService_SelectAccountWithScheduler_CompactFallsBackToUnknown(t *testing.T) { diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 3b3a64856e..081cf30f9a 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -196,6 +196,13 @@ func (c *schedulerTestGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ return nil } +func (c *schedulerTestGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *schedulerTestGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func newSchedulerTestOpenAIWSV2Config() *config.Config { cfg := &config.Config{} cfg.Gateway.OpenAIWS.Enabled = true @@ -459,6 +466,75 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabledUsesLega require.False(t, decision.StickyPreviousHit) } +// Regression: the legacy load-batch path had two bare ErrNoAvailableAccounts +// exits that bypassed the diagnostics added for both the advanced scheduler and +// the non-batched legacy selector. This is the default path when load batching +// is enabled, so quota auto-pause could still surface as an opaque 503. +func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_LoadBatchReportsFilterReasons(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := withOpenAIQuotaAutoPauseSettings(context.Background(), OpsOpenAIAccountQuotaAutoPauseSettings{DefaultThreshold7d: 0.9}) + groupID := int64(10107) + quotaPaused := Account{ + ID: 36003, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + "codex_usage_updated_at": time.Now().Add(-time.Minute).Format(time.RFC3339), + }, + } + mappingMiss := Account{ + ID: 36004, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-4o": "gpt-4o"}, + }, + } + excluded := Account{ + ID: 36005, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + } + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{quotaPaused, mappingMiss, excluded}}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + require.False(t, svc.isOpenAIAdvancedSchedulerEnabled(ctx)) + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &groupID, + "", + "", + "gpt-5.4-mini", + map[int64]struct{}{excluded.ID: {}}, + OpenAIUpstreamTransportAny, + false, + ) + + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Nil(t, selection) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.EqualError(t, err, "no available OpenAI accounts supporting model: gpt-5.4-mini (pool=3, filtered: excluded=1 model_not_supported=1 quota_auto_pause_7d=1)") +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_RequiredWSV2_SkipsHTTPOnlyAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -613,6 +689,59 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_Embeddi require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) } +func TestOpenAIGatewayService_SelectAccountForTokenCount_DoesNotAcquireGenerationSlot(t *testing.T) { + ctx := context.Background() + groupID := int64(10115) + acquiredIDs := make([]int64, 0) + accounts := []Account{ + { + ID: 36501, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + Credentials: map[string]any{"openai_capabilities": []any{"chat_completions"}}, + }, + { + ID: 36502, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, + Credentials: map[string]any{"openai_capabilities": []any{"embeddings"}}, + }, + { + ID: 36503, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + Credentials: map[string]any{"openai_capabilities": []any{"chat_completions"}}, + }, + { + ID: 36504, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 15, + Credentials: map[string]any{ + "openai_capabilities": []any{"chat_completions"}, + "model_mapping": map[string]any{"gpt-4o": "gpt-4o"}, + }, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: &config.Config{}, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{36501: false}, + acquiredIDs: &acquiredIDs, + }), + } + + account, err := svc.SelectAccountForTokenCount( + ctx, + &groupID, + "", + "gpt-5.1", + OpenAIEndpointCapabilityChatCompletions, + PlatformOpenAI, + ) + require.NoError(t, err) + require.NotNil(t, account) + require.Equal(t, int64(36501), account.ID) + require.Empty(t, acquiredIDs, "token counting must not acquire a generation slot") +} + // 生图意图的 /v1/responses 请求要求 OpenAIEndpointCapabilityResponses:探测确认 // 不支持 Responses API 的 APIKey 账号必须被排除,避免 forward 阶段降级为无法生图 // 的 Chat Completions 直转(#4417)。 @@ -1448,7 +1577,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *test svc := &OpenAIGatewayService{} ttft := 120 - svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft) + svc.ReportOpenAIAccountScheduleResult(&Account{ID: 10}, "", true, &ttft) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() @@ -2680,7 +2809,7 @@ func TestReportOpenAIAccountScheduleResult_SuccessClearsModelTransientState(t *t svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now.Add(time.Millisecond)) require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond))) - svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil) + svc.ReportOpenAIAccountScheduleResult(&Account{ID: 21636}, "gpt-5.5", true, nil) require.False(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond))) } @@ -3140,7 +3269,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) { selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - svc.ReportOpenAIAccountScheduleResult(account.ID, "", true, intPtrForTest(120)) + svc.ReportOpenAIAccountScheduleResult(&account, "", true, intPtrForTest(120)) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() @@ -3477,7 +3606,7 @@ func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) { svc := &OpenAIGatewayService{} ttft := 120 - svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft) + svc.ReportOpenAIAccountScheduleResult(&Account{ID: 10}, "", true, &ttft) svc.RecordOpenAIAccountSwitch() snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics() require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot) diff --git a/backend/internal/service/openai_agent_identity_compat_test.go b/backend/internal/service/openai_agent_identity_compat_test.go index 5dad35887b..2520eaaaeb 100644 --- a/backend/internal/service/openai_agent_identity_compat_test.go +++ b/backend/internal/service/openai_agent_identity_compat_test.go @@ -40,7 +40,7 @@ func TestAccountTestServiceOpenAICompactAgentIdentityUsesFreshAssertion(t *testi upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed"}`)), + Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed","output":[{"type":"compaction","id":"cmp_agent_fresh","encrypted_content":"blob"}]}`)), }} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} @@ -87,7 +87,7 @@ func TestAccountTestServiceOpenAICompactAgentIdentityRecoversInvalidTaskOnce(t * upstream := &httpUpstreamRecorder{responses: []*http.Response{ {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))}, - {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed"}`))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed","output":[{"type":"compaction","id":"cmp_agent","encrypted_content":"blob"}]}`))}, }} invalidator := &agentIdentityWSInvalidationRecorder{} svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, agentIdentityWS: invalidator} @@ -133,8 +133,8 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id")) require.NotEqual(t, "client-session", req.Header.Get("session_id")) require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id")) - require.Equal(t, isolateOpenAISessionID(0, "client-session"), req.Header.Get("session_id")) - require.Equal(t, isolateOpenAISessionID(0, "client-conversation"), req.Header.Get("conversation_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-session"), req.Header.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-conversation"), req.Header.Get("conversation_id")) requestBody, err := io.ReadAll(req.Body) require.NoError(t, err) require.Contains(t, string(requestBody), `"prompt_cache_key":"cache-agent"`) @@ -147,7 +147,7 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "chatgpt_account_id": "account-oauth-passthrough", + "chatgpt_account_id": "account-agent-passthrough", }, } oauthRecorder := httptest.NewRecorder() diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 0ddc8f5255..ac242f050c 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -31,6 +30,9 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if s == nil || c == nil || account == nil { return nil, fmt.Errorf("service, context, and account are required") } + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } modelResult := gjson.GetBytes(body, "model") requestedModel := strings.TrimSpace(modelResult.String()) if modelResult.Type != gjson.String || requestedModel == "" { @@ -59,6 +61,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if err := s.ensureOpenAIAlphaSearchAuthMetadata(ctx, account, token, proxyURL); err != nil { return nil, err } + SetOpsUpstreamModel(c, upstreamModel) // Codex Personal Access Token(at-...)目前可访问 ChatGPT Codex // /responses,但会被 standalone /alpha/search 的 access enforcement @@ -74,7 +77,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co } upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(req, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) @@ -98,13 +101,16 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co // 真正的凭据失效由普通 Responses 请求或 whoami 校验判定。 shouldDisable := false if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) { - shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) + shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel)) } - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode) + if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests { + return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount) } + if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMessage, respBody) { + return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount) + } + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount} } } @@ -154,7 +160,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( SetActualOpenAIUpstreamEndpoint(c, "/v1/responses") upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(req, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) @@ -173,13 +179,16 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( // 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。 shouldDisable := false if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) { - shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) + shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel)) } - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode) + if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests { + return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount) } + if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMessage, respBody) { + return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount) + } + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount} } } @@ -212,6 +221,10 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( }, nil } +func openAIAlphaSearchSchedulingModel(account *Account, requestedModel string) string { + return canonicalOpenAIAccountSchedulingModel(account, requestedModel) +} + func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx context.Context, c *gin.Context, account *Account, alphaBody []byte, body []byte, token string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body)) if err != nil { @@ -239,32 +252,34 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) } else { - req.Header.Set("Version", codexCLIVersion) + req.Header.Set("Version", canonical.version) } if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", openai.CodexDefaultOriginator) + req.Header.Set("Originator", canonical.originator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { - isolated := isolateOpenAISessionID(apiKeyID, sessionID) + isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID) req.Header.Set("Session_ID", isolated) req.Header.Set("Conversation_ID", isolated) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil @@ -382,25 +397,27 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) } else { - req.Header.Set("Version", codexCLIVersion) + req.Header.Set("Version", canonical.version) } if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", openai.CodexDefaultOriginator) + req.Header.Set("Originator", canonical.originator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } @@ -448,6 +465,7 @@ var openAIAlphaSearchUnsupportedBodyFields = [...]string{ // alpha/search 会对这些字段返回 Unknown parameter(例如 prompt_cache_key)。 "prompt_cache_key", "prompt_cache_retention", + "store", } func sanitizeOpenAIAlphaSearchBody(body []byte) ([]byte, error) { @@ -651,7 +669,7 @@ func (s *OpenAIGatewayService) openAIAlphaSearchURL(account *Account) (string, e return "", fmt.Errorf("account is required") } switch account.Type { - case AccountTypeOAuth: + case AccountTypeOAuth, AccountTypeSetupToken: return chatgptCodexAlphaSearchURL, nil case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index 99cacc88f7..b8c6236d9d 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -5,6 +5,7 @@ package service import ( "context" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" @@ -50,7 +51,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 即使 token 倍率(含高峰,3.0)更高也不采用。 apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} - cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false)) + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), time.Time{}) require.NoError(t, err) require.Equal(t, string(BillingModePerRequest), cost.BillingMode) require.InDelta(t, 0.01, cost.TotalCost, 1e-12) @@ -58,7 +59,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 分组配置单价 0.005 apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false)) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), time.Time{}) require.NoError(t, err) require.InDelta(t, 0.005, cost.TotalCost, 1e-12) require.InDelta(t, 0.005, cost.ActualCost, 1e-12) @@ -66,7 +67,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, // 证明回落到了 token 路径而不是被按次分支吞掉)。 result.WebSearchCalls = 0 - _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false)) + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), time.Time{}) require.Error(t, err) } diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index c4eded7d59..8521c2160e 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -58,6 +59,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { c.Request.Header.Set("User-Agent", codexCLIUserAgent) c.Request.Header.Set("Originator", "codex_cli_rs") c.Request.Header.Set("Version", "0.144.1") + c.Request.Header.Set("X-Codex-Turn-Metadata", `{"session_id":"search-session","turn_id":"search-turn"}`) upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, @@ -91,6 +93,14 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "session", "search-session"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "session_id").String(), + ) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "turn", "search-turn"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), + ) require.JSONEq(t, string(body), string(upstream.lastBody)) } @@ -155,7 +165,10 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) - require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata")) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "turn", "turn-1"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), + ) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State")) @@ -300,11 +313,144 @@ func TestForwardAlphaSearchReturnsFailoverBeforeWriting(t *testing.T) { var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.Empty(t, failoverErr.Stage) + require.Empty(t, failoverErr.Scope) + require.Empty(t, failoverErr.Reason) + require.Zero(t, failoverErr.ClientStatusCode) + require.Empty(t, failoverErr.ClientMessage) + require.Nil(t, failoverErr.ResponseHeaders) require.Equal(t, openAIPlatformAlphaSearchURL, upstream.lastReq.URL.String()) require.False(t, c.Writer.Written()) require.Empty(t, recorder.Body.String()) } +func TestForwardAlphaSearchSetupToken429CarriesSameAccountRetryWindow(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Retry-After": []string{"1"}, + "X-Request-Id": []string{"req_alpha_oauth_429"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)), + }} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 81, + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + } + startedAt := time.Now() + + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, time.Second, failoverErr.SameAccountRetryDelay) + require.WithinDuration(t, startedAt.Add(openAIOAuth429RetryWindow), failoverErr.SameAccountRetryDeadline, time.Second) + require.Equal(t, "req_alpha_oauth_429", failoverErr.ResponseHeaders.Get("x-request-id")) + require.False(t, c.Writer.Written()) +} + +func TestForwardAlphaSearchAccessStateUsesTypedFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_alpha_access_state"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"deactivated_workspace","message":"Workspace is deactivated"}}`)), + }} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 11, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + } + + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + + require.Nil(t, result) + assertOpenAIAlphaSearchAccessStateFailover(t, err, "req_alpha_access_state") + require.Equal(t, chatgptCodexAlphaSearchURL, upstream.lastReq.URL.String()) + require.False(t, c.Writer.Written()) +} + +func TestForwardAlphaSearchPATFallbackAccessStateUsesTypedFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_alpha_pat_access_state"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"account_disabled","message":"Account is disabled"}}`)), + }} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 12, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "at-test-token", + "auth_mode": OpenAIAuthModePersonalAccessToken, + "chatgpt_account_id": "chatgpt-account", + }, + } + + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + + require.Nil(t, result) + assertOpenAIAlphaSearchAccessStateFailover(t, err, "req_alpha_pat_access_state") + require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) + require.False(t, c.Writer.Written()) +} + +func assertOpenAIAlphaSearchAccessStateFailover(t *testing.T, err error, requestID string) { + t.Helper() + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusForbidden, failoverErr.StatusCode) + require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage) + require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope) + require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason) + require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction) + require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode) + require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage) + require.False(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, requestID, failoverErr.ResponseHeaders.Get("x-request-id")) +} + func TestForwardAlphaSearchUnauthorizedDoesNotMarkAccountError(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`) @@ -482,6 +628,24 @@ func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) { require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests)) } +func TestOpenAIAlphaSearchSchedulingModelUsesCanonicalAccountMapping(t *testing.T) { + account := &Account{Credentials: map[string]any{ + "model_mapping": map[string]any{"client-visible": "canonical-upstream"}, + }} + require.Equal(t, "canonical-upstream", openAIAlphaSearchSchedulingModel(account, "client-visible")) + require.Equal(t, "unmapped", openAIAlphaSearchSchedulingModel(account, "unmapped")) +} + +func TestSanitizeOpenAIAlphaSearchBody_RemovesResponsesOnlyFields(t *testing.T) { + body := []byte(`{"id":"search-session","store":false,"prompt_cache_key":"cache","commands":{"search_query":[{"q":"news"}]}}`) + + normalized, err := sanitizeOpenAIAlphaSearchBody(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(normalized, "store").Exists()) + require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists()) + require.Equal(t, "news", gjson.GetBytes(normalized, "commands.search_query.0.q").String()) +} + func TestIsOpenAIAlphaSearchEndpointUnsupported(t *testing.T) { apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} diff --git a/backend/internal/service/openai_apikey_health_breaker.go b/backend/internal/service/openai_apikey_health_breaker.go new file mode 100644 index 0000000000..65581ae3d2 --- /dev/null +++ b/backend/internal/service/openai_apikey_health_breaker.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "go.uber.org/zap" +) + +const openAIAPIKeyHealthBreakerReason = "openai_apikey_health_breaker" + +func isOpenAIAPIKeyHealthBreakerAccount(account *Account) bool { + return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && account.IsPoolMode() +} + +func classifyOpenAIAPIKeyHealthFailure(err error) (int, []byte, bool) { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return 0, nil, false + } + + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) { + // These failures already have dedicated recovery/state handling or are not + // attributable to the selected account. + if failoverErr.IsCredentialFailure() || + failoverErr.RequestScopedTransient || + failoverErr.RetryableOnSameAccount || + failoverErr.Scope == GatewayFailureScopeRequest || + failoverErr.Scope == GatewayFailureScopeProvider { + return failoverErr.StatusCode, failoverErr.ResponseBody, false + } + if failoverErr.StatusCode == http.StatusTooManyRequests || failoverErr.StatusCode >= http.StatusInternalServerError { + return failoverErr.StatusCode, failoverErr.ResponseBody, true + } + return failoverErr.StatusCode, failoverErr.ResponseBody, false + } + + var imageErr *OpenAIImagesUpstreamError + if errors.As(err, &imageErr) { + if imageErr.StatusCode == http.StatusTooManyRequests || imageErr.StatusCode >= http.StatusInternalServerError { + return imageErr.StatusCode, []byte(strings.TrimSpace(imageErr.Message)), true + } + } + return 0, nil, false +} + +func (s *RateLimitService) ObserveOpenAIAPIKeyHealthFailure(ctx context.Context, account *Account, upstreamErr error) bool { + if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || s.accountRepo == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) { + return false + } + statusCode, responseBody, eligible := classifyOpenAIAPIKeyHealthFailure(upstreamErr) + if !eligible { + return false + } + settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx) + if err != nil { + logger.L().Warn("openai.apikey_health_breaker_settings_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + return false + } + if settings == nil || !settings.Enabled { + return false + } + + count, tripped, err := s.openAIAPIKeyHealth.RecordOpenAIAPIKeyHealthFailure(ctx, account.ID, settings.WindowMinutes, settings.FailureThreshold) + if err != nil { + logger.L().Warn("openai.apikey_health_breaker_record_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + return false + } + if !tripped { + return false + } + + now := time.Now() + until := now.Add(time.Duration(settings.CooldownMinutes) * time.Minute) + state := &TempUnschedState{ + UntilUnix: until.Unix(), + TriggeredAtUnix: now.Unix(), + StatusCode: statusCode, + MatchedKeyword: openAIAPIKeyHealthBreakerReason, + RuleIndex: -1, + ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes), + TriggerCount: count, + TriggerThreshold: settings.FailureThreshold, + TriggerWindowMinutes: settings.WindowMinutes, + } + reasonBytes, _ := json.Marshal(state) + reason := string(reasonBytes) + if reason == "" { + reason = fmt.Sprintf("%s: %d failures in %d minute(s)", openAIAPIKeyHealthBreakerReason, count, settings.WindowMinutes) + } + + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancel() + if err := s.accountRepo.SetTempUnschedulable(persistCtx, account.ID, until, reason); err != nil { + logger.L().Warn("openai.apikey_health_breaker_persist_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + return false + } + + if account.TempUnschedulableUntil == nil || account.TempUnschedulableUntil.Before(until) { + account.TempUnschedulableUntil = &until + account.TempUnschedulableReason = reason + } + s.notifyAccountSchedulingBlocked(account, until, openAIAPIKeyHealthBreakerReason) + if s.tempUnschedCache != nil { + if err := s.tempUnschedCache.SetTempUnsched(persistCtx, account.ID, state); err != nil { + logger.L().Warn("openai.apikey_health_breaker_cache_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + } + logger.L().Warn("openai.apikey_health_breaker_tripped", + zap.Int64("account_id", account.ID), + zap.Int64("failure_count", count), + zap.Int("failure_threshold", settings.FailureThreshold), + zap.Int("window_minutes", settings.WindowMinutes), + zap.Int("cooldown_minutes", settings.CooldownMinutes), + zap.Int("upstream_status", statusCode), + zap.Time("until", until), + ) + return true +} + +func (s *RateLimitService) ObserveOpenAIAPIKeyHealthSuccess(context.Context, *Account) { + // Health failures are accumulated in a rolling time window. A success does + // not reset that window and must not add a Redis round trip to the hot path. +} diff --git a/backend/internal/service/openai_apikey_health_breaker_test.go b/backend/internal/service/openai_apikey_health_breaker_test.go new file mode 100644 index 0000000000..4c58ed1b4a --- /dev/null +++ b/backend/internal/service/openai_apikey_health_breaker_test.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type openAIAPIKeyHealthSettingRepo struct { + SettingRepository + value string + getCalls int +} + +func (r *openAIAPIKeyHealthSettingRepo) GetValue(context.Context, string) (string, error) { + r.getCalls++ + return r.value, nil +} + +type openAIAPIKeyHealthAccountRepo struct { + AccountRepository + setCalls int + reason string +} + +func (r *openAIAPIKeyHealthAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error { + r.setCalls++ + r.reason = reason + return nil +} + +type openAIAPIKeyHealthCacheStub struct { + TempUnschedCache + recordCalls int + setCalls int + tripped bool +} + +func (c *openAIAPIKeyHealthCacheStub) RecordOpenAIAPIKeyHealthFailure(context.Context, int64, int, int) (int64, bool, error) { + c.recordCalls++ + return 3, c.tripped, nil +} + +func (c *openAIAPIKeyHealthCacheStub) SetTempUnsched(context.Context, int64, *TempUnschedState) error { + c.setCalls++ + return nil +} + +type openAIAPIKeyHealthRuntimeBlocker struct{ calls int } + +func (b *openAIAPIKeyHealthRuntimeBlocker) BlockAccountScheduling(*Account, time.Time, string) { + b.calls++ +} +func (*openAIAPIKeyHealthRuntimeBlocker) ClearAccountSchedulingBlock(int64) {} + +func openAIHealthPoolAccount() *Account { + return &Account{ + ID: 42, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + }, + } +} + +func TestClassifyOpenAIAPIKeyHealthFailureExclusions(t *testing.T) { + tests := []struct { + name string + err error + eligible bool + }{ + {name: "account attributed 502", err: &UpstreamFailoverError{StatusCode: http.StatusBadGateway}, eligible: true}, + {name: "request scoped capacity", err: &UpstreamFailoverError{StatusCode: 529, RequestScopedTransient: true}}, + {name: "provider scoped overload", err: &UpstreamFailoverError{StatusCode: 529, Scope: GatewayFailureScopeProvider}}, + {name: "dedicated same account retry", err: &UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, RetryableOnSameAccount: true}}, + {name: "credential disable path", err: &UpstreamFailoverError{StatusCode: http.StatusUnauthorized, Stage: GatewayFailureStageAccountAuth, Scope: GatewayFailureScopeAccount}}, + {name: "client request", err: &UpstreamFailoverError{StatusCode: http.StatusBadRequest}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, eligible := classifyOpenAIAPIKeyHealthFailure(tt.err) + require.Equal(t, tt.eligible, eligible) + }) + } +} + +func TestOpenAIAPIKeyHealthBreakerDefaultDisabled(t *testing.T) { + settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{}, &config.Config{}) + cache := &openAIAPIKeyHealthCacheStub{tripped: true} + svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache) + svc.SetSettingService(settings) + svc.SetOpenAIAPIKeyHealthCache(cache) + + require.False(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), openAIHealthPoolAccount(), &UpstreamFailoverError{StatusCode: http.StatusBadGateway})) + require.Zero(t, cache.recordCalls) +} + +func TestOpenAIAPIKeyHealthBreakerTripsPersistedAndRuntimeState(t *testing.T) { + encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5}) + require.NoError(t, err) + settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{value: string(encoded)}, &config.Config{}) + cache := &openAIAPIKeyHealthCacheStub{tripped: true} + repo := &openAIAPIKeyHealthAccountRepo{} + blocker := &openAIAPIKeyHealthRuntimeBlocker{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, cache) + svc.SetSettingService(settings) + svc.SetOpenAIAPIKeyHealthCache(cache) + svc.SetAccountRuntimeBlocker(blocker) + account := openAIHealthPoolAccount() + + require.True(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, ResponseBody: []byte(`{"error":"upstream"}`)})) + require.Equal(t, 1, cache.recordCalls) + require.Equal(t, 1, cache.setCalls) + require.Equal(t, 1, repo.setCalls) + require.Equal(t, 1, blocker.calls) + require.NotNil(t, account.TempUnschedulableUntil) + require.Contains(t, repo.reason, openAIAPIKeyHealthBreakerReason) +} + +func TestOpenAIAPIKeyHealthSuccessDoesNotTouchSettingsOrCache(t *testing.T) { + encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5}) + require.NoError(t, err) + settingRepo := &openAIAPIKeyHealthSettingRepo{value: string(encoded)} + settings := NewSettingService(settingRepo, &config.Config{}) + cache := &openAIAPIKeyHealthCacheStub{} + svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache) + svc.SetSettingService(settings) + svc.SetOpenAIAPIKeyHealthCache(cache) + + svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), openAIHealthPoolAccount()) + svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + require.Zero(t, settingRepo.getCalls) + require.Zero(t, cache.recordCalls) +} diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 79db519e6f..329cfebad4 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -119,7 +119,29 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte logger.LegacyPrintf("service.openai_probe", "probe_load_account_failed: account_id=%d err=%v", accountID, err) return } - if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + if account.Type != AccountTypeAPIKey { + return + } + if account.IsCNProvider() { + // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions, + // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。 + // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses + // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。 + if account.GetAPIProtocol() == APIProtocolResponses || + (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) { + _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + openai_compat.ExtraKeyResponsesSupported: true, + }) + return + } + _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto), + openai_compat.ExtraKeyResponsesSupported: false, + }) + return + } + if account.Platform != PlatformOpenAI { // 仅 OpenAI APIKey 账号需要探测;其他账号类型无能力差异。 return } diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go index fac351a0ec..f3b4eb0e6d 100644 --- a/backend/internal/service/openai_apikey_responses_probe_test.go +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -49,6 +49,46 @@ func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) { require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported]) } +func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) { + tests := []struct { + name string + id int64 + platform string + protocol string + wantSupport bool + wantMode string + }{ + {name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, + {name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updateCalls := make(chan map[string]any, 1) + account := Account{ + ID: tc.id, Platform: tc.platform, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "api_protocol": tc.protocol}, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + }, + } + repo := &snapshotUpdateAccountRepo{ + stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + updateExtraCalls: updateCalls, + } + svc := &AccountTestService{accountRepo: repo} + + svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID) + + updates := <-updateCalls + require.Equal(t, tc.wantSupport, updates[openai_compat.ExtraKeyResponsesSupported]) + require.Equal(t, tc.wantMode, updates[openai_compat.ExtraKeyResponsesMode]) + }) + } +} + func TestDecideResponsesProbeSupport(t *testing.T) { fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`) reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`) diff --git a/backend/internal/service/openai_bulk_account_settings.go b/backend/internal/service/openai_bulk_account_settings.go new file mode 100644 index 0000000000..f418e575f4 --- /dev/null +++ b/backend/internal/service/openai_bulk_account_settings.go @@ -0,0 +1,225 @@ +package service + +import ( + "fmt" + "strconv" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" +) + +type bulkOpenAISettings struct { + longContextBilling bool + endpointCapabilities bool + responsesMode bool + capabilitiesIncludeChat bool + forcedResponsesMode bool +} + +func (s bulkOpenAISettings) any() bool { + return s.longContextBilling || s.endpointCapabilities || s.responsesMode +} + +func normalizeBulkOpenAISettings(input *BulkUpdateAccountsInput) (bulkOpenAISettings, error) { + var settings bulkOpenAISettings + if input == nil { + return settings, nil + } + + if _, exists := input.Extra[openAILongContextBillingEnabledKey]; exists { + settings.longContextBilling = true + if err := ValidateOpenAILongContextBillingExtra(PlatformOpenAI, input.Extra); err != nil { + return settings, err + } + } + + if raw, exists := input.Credentials[openAIEndpointCapabilitiesCredentialKey]; exists { + settings.endpointCapabilities = true + capabilities, includeChat, err := normalizeBulkOpenAIEndpointCapabilities(raw) + if err != nil { + return settings, err + } + settings.capabilitiesIncludeChat = includeChat + input.Credentials[openAIEndpointCapabilitiesCredentialKey] = capabilities + } + + if raw, exists := input.Extra[openai_compat.ExtraKeyResponsesMode]; exists { + settings.responsesMode = true + mode, forced, err := normalizeBulkOpenAIResponsesMode(raw) + if err != nil { + return settings, err + } + settings.forcedResponsesMode = forced + input.Extra[openai_compat.ExtraKeyResponsesMode] = mode + } + + if settings.endpointCapabilities && !settings.capabilitiesIncludeChat { + if settings.forcedResponsesMode { + return settings, infraerrors.BadRequest( + "OPENAI_RESPONSES_MODE_INVALID", + "a forced Responses route requires the chat_completions endpoint capability", + ) + } + if input.Extra == nil { + input.Extra = make(map[string]any, 1) + } + input.Extra[openai_compat.ExtraKeyResponsesMode] = nil + settings.responsesMode = true + } + + return settings, nil +} + +func normalizeBulkOpenAIEndpointCapabilities(raw any) (any, bool, error) { + if raw == nil { + return nil, true, nil + } + + values := make([]string, 0, 2) + switch typed := raw.(type) { + case []any: + for _, item := range typed { + value, ok := item.(string) + if !ok { + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + values = append(values, value) + } + case []string: + values = append(values, typed...) + default: + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + + selected := make(map[string]bool, 2) + for _, value := range values { + switch OpenAIEndpointCapability(value) { + case OpenAIEndpointCapabilityChatCompletions, OpenAIEndpointCapabilityEmbeddings: + selected[value] = true + default: + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + } + if len(selected) == 0 { + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + + includeChat := selected[string(OpenAIEndpointCapabilityChatCompletions)] + if includeChat && selected[string(OpenAIEndpointCapabilityEmbeddings)] { + return nil, true, nil + } + if includeChat { + return []string{string(OpenAIEndpointCapabilityChatCompletions)}, true, nil + } + return []string{string(OpenAIEndpointCapabilityEmbeddings)}, false, nil +} + +func invalidBulkOpenAIEndpointCapabilities() error { + return infraerrors.BadRequest( + "OPENAI_ENDPOINT_CAPABILITIES_INVALID", + "openai_capabilities must contain chat_completions, embeddings, or both", + ) +} + +func normalizeBulkOpenAIResponsesMode(raw any) (any, bool, error) { + if raw == nil { + return nil, false, nil + } + mode, ok := raw.(string) + if !ok { + return nil, false, invalidBulkOpenAIResponsesMode() + } + switch openai_compat.ResponsesSupportMode(mode) { + case openai_compat.ResponsesSupportModeAuto: + return nil, false, nil + case openai_compat.ResponsesSupportModeForceResponses, + openai_compat.ResponsesSupportModeForceChatCompletions: + return mode, true, nil + default: + return nil, false, invalidBulkOpenAIResponsesMode() + } +} + +func invalidBulkOpenAIResponsesMode() error { + return infraerrors.BadRequest( + "OPENAI_RESPONSES_MODE_INVALID", + "openai_responses_mode must be auto, force_responses, force_chat_completions, or null", + ) +} + +func validateBulkOpenAISettingsTargets( + input *BulkUpdateAccountsInput, + settings bulkOpenAISettings, + targetsByID map[int64]*Account, +) (int, error) { + if input == nil || !settings.any() { + return 0, nil + } + + inheritedCount := 0 + for _, accountID := range input.AccountIDs { + account, ok := targetsByID[accountID] + if !ok || account == nil { + return 0, invalidBulkOpenAITarget(accountID, "account does not exist") + } + + if settings.longContextBilling { + if account.Platform != PlatformOpenAI || !supportsOpenAILongContextBilling(account.Type) { + return 0, invalidBulkOpenAITarget(accountID, "long-context billing requires an OpenAI OAuth, setup-token, or API-key account") + } + if account.IsShadow() { + inheritedCount++ + } + } + + if settings.endpointCapabilities || settings.responsesMode { + if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + return 0, invalidBulkOpenAITarget(accountID, "endpoint capabilities and Responses routing require an OpenAI API-key account") + } + } + + if settings.forcedResponsesMode && !settings.capabilitiesIncludeChat && + !settings.endpointCapabilities && + !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions) { + return 0, invalidBulkOpenAITarget(accountID, "a forced Responses route requires the chat_completions endpoint capability") + } + } + + if settings.longContextBilling && inheritedCount == len(input.AccountIDs) && bulkUpdateOnlyChangesLongContext(input) { + return 0, infraerrors.BadRequest( + "OPENAI_LONG_CONTEXT_PARENT_REQUIRED", + "long-context billing is owned by parent accounts; select at least one parent account", + ) + } + return inheritedCount, nil +} + +func supportsOpenAILongContextBilling(accountType string) bool { + switch accountType { + case AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey: + return true + default: + return false + } +} + +func invalidBulkOpenAITarget(accountID int64, message string) error { + return infraerrors.BadRequest( + "OPENAI_BULK_TARGET_INVALID", + fmt.Sprintf("account %d: %s", accountID, message), + ).WithMetadata(map[string]string{"account_id": strconv.FormatInt(accountID, 10)}) +} + +func bulkUpdateOnlyChangesLongContext(input *BulkUpdateAccountsInput) bool { + if input == nil || input.Name != "" || input.ProxyID != nil || input.Concurrency != nil || + input.Priority != nil || input.RateMultiplier != nil || input.LoadFactor != nil || + input.Status != "" || input.Schedulable != nil || input.GroupIDs != nil || + len(input.Credentials) != 0 || input.ProbeEnabled != nil { + return false + } + if len(input.Extra) != 1 { + return false + } + _, ok := input.Extra[openAILongContextBillingEnabledKey] + return ok +} diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index e68a48db1c..ac32fa5749 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -77,6 +77,37 @@ func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) { require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom")) } +func TestOpenAIHTTPCapacityShedIsRequestScopedForOAuthAccounts(t *testing.T) { + payload := []byte(`{"error":{"type":"server_error","message":"Our servers are currently overloaded. Please try again later."}}`) + failoverErr := newOpenAIUpstreamFailoverError( + http.StatusBadRequest, + http.Header{"X-Request-Id": []string{"rid-http-capacity"}}, + payload, + "Our servers are currently overloaded. Please try again later.", + false, + ) + + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + + repo := &capacityShedAccountRepoStub{} + (&GatewayService{accountRepo: repo}).TempUnscheduleRetryableError(context.Background(), 1, failoverErr) + require.Zero(t, repo.tempUnschedCalls) + + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + gateway := &OpenAIGatewayService{rateLimitService: rateLimitService} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + require.False(t, gateway.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + nil, + payload, + "gpt-5", + )) + require.Zero(t, repo.tempUnschedCalls) +} + // 上游降载的真实序列是「event: error → event: response.failed」。error 帧不算 // 客户端输出:若把它当首输出 flush,clientOutputStarted 被固化,随后的 failed // 事件就进不了 pre-output failover 分支,只能把致命错误原样转发给客户端。 @@ -94,6 +125,11 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) { {`{"type":"response.failed","response":{"error":{"code":"server_is_overloaded"}}}`, "response.failed", false}, {`{"type":"response.created","response":{"id":"resp_1"}}`, "response.created", false}, {`{"type":"response.in_progress","response":{"id":"resp_1"}}`, "response.in_progress", false}, + {`{"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`, "response.output_item.added", false}, + {`{"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"ciphertext"}}`, "response.output_item.added", true}, + {`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, "response.reasoning_summary_part.added", false}, + {`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":"thinking"}}`, "response.reasoning_summary_part.added", true}, + {`{"type":"response.content_part.added","part":{"type":"output_text","text":""}}`, "response.content_part.added", false}, {`{"type":"response.output_text.delta","delta":"hi"}`, "response.output_text.delta", true}, {`[DONE]`, "", true}, } @@ -102,6 +138,71 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) { } } +func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T) { + gin.SetMode(gin.TestMode) + largeMetadata := strings.Repeat("x", 16*1024) + stream := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1","metadata":{"padding":"` + largeMetadata + `"}}}`, + "", + "event: response.output_item.added", + `data: {"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`, + "", + "event: response.reasoning_summary_part.added", + `data: {"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, + "", + "event: error", + `data: {"type":"error","error":{"type":"service_unavailable_error","message":"Our servers are currently overloaded. Please try again later."}}`, + "", + }, "\n") + + tests := []struct { + name string + run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error + }{ + { + name: "native", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model") + return err + }, + }, + { + name: "passthrough", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "model", "model") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(stream)), + Header: http.Header{"X-Request-Id": []string{"rid-message-only-overload"}}, + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"} + + err := tt.run(svc, c, resp, account) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.Equal(t, http.StatusServiceUnavailable, failoverErr.ClientStatusCode) + require.Contains(t, failoverErr.ClientMessage, "servers are currently overloaded") + require.False(t, c.Writer.Written()) + require.Empty(t, rec.Body.String()) + }) + } +} + // 回归用例(真实上游降载序列):created → in_progress → error 帧 → response.failed。 // 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。 func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) { @@ -145,10 +246,12 @@ func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *test } // 流中途(已有真实输出)降载时无法再 failover,此时必须把降载码改写为客户端 -// 可重试的 server_error 再转发——Codex 对 server_is_overloaded/slow_down 判致命 -// 并终止会话,对其余错误码执行内置退避重试。消息原样保留。 +// 可重试的 server_error 再通过唯一 response.failed 终态转发——Codex 对 +// server_is_overloaded/slow_down 判致命并终止会话,对其余错误码执行内置退避重试。 func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) { gin.SetMode(gin.TestMode) + logSink, restore := captureStructuredLog(t) + defer restore() cfg := &config.Config{ Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, } @@ -184,10 +287,15 @@ func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) body := rec.Body.String() require.Contains(t, body, "partial") - require.Contains(t, body, "event: response.failed") + require.NotContains(t, body, "event: error") + require.Equal(t, 1, strings.Count(body, "event: response.failed")) + require.Equal(t, 1, strings.Count(body, `"code":"server_error"`)) require.Contains(t, body, `"code":"server_error"`) require.NotContains(t, body, "server_is_overloaded") require.Contains(t, body, "Our servers are currently overloaded") + require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output")) + require.True(t, logSink.ContainsFieldValue("path", "native_sse")) + require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-shed-after-output")) } // helper 单测:只有降载码被改写,其余错误码(尤其 rate_limit_exceeded,客户端 @@ -211,6 +319,18 @@ func TestSanitizeOpenAICapacityShedErrorCodeForClient(t *testing.T) { wantChanged: true, wantContain: `"code":"server_error"`, }, + { + name: "failed事件只有过载文案时补充code", + payload: `{"type":"response.failed","response":{"error":{"message":"Our servers are currently overloaded. Please try again later."}}}`, + wantChanged: true, + wantContain: `"code":"server_error"`, + }, + { + name: "error帧只有过载文案时补充code", + payload: `{"type":"error","error":{"message":"Server is overloaded. Please try again later."}}`, + wantChanged: true, + wantContain: `"code":"server_error"`, + }, { name: "rate_limit不改写", payload: `{"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"try again in 3s"}}}`, @@ -250,7 +370,6 @@ func TestCodexOutboundVersionHasSingleSource(t *testing.T) { strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "), "codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion, ) - require.Equal(t, codexCLIVersion, openAICodexProbeVersion) require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0, "codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion, ) diff --git a/backend/internal/service/openai_channel_restriction_test.go b/backend/internal/service/openai_channel_restriction_test.go index c9dbceabe0..098f053c3d 100644 --- a/backend/internal/service/openai_channel_restriction_test.go +++ b/backend/internal/service/openai_channel_restriction_test.go @@ -86,6 +86,226 @@ func TestOpenAISelectAccountForModelWithExclusions_UpstreamRestrictionSkipsDisal require.Equal(t, int64(2), account.ID) } +func TestIsUpstreamModelRestrictedByChannel_CompactMappingMatchesForwardPath(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.4-channel": "gpt-5.4-account"}, + "compact_model_mapping": map[string]any{"gpt-5.4-account": "gpt-5.4-compact"}, + }, + } + tests := []struct { + name string + allowedUpstreamModel string + useCompactModelMapping bool + }{ + { + name: "legacy compact applies compact mapping after channel and account mapping", + allowedUpstreamModel: "gpt-5.4-compact", + useCompactModelMapping: true, + }, + { + name: "native v2 stops after channel and account mapping", + allowedUpstreamModel: "gpt-5.4-account", + useCompactModelMapping: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + channelSvc := newTestChannelService(makeStandardRepo(Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + RestrictModels: true, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformOpenAI, Models: []string{tt.allowedUpstreamModel}}, + }, + ModelMapping: map[string]map[string]string{ + PlatformOpenAI: {"gpt-5.4": "gpt-5.4-channel"}, + }, + }, map[int64]string{10: PlatformOpenAI})) + svc := &OpenAIGatewayService{channelService: channelSvc} + mapping := channelSvc.ResolveChannelMapping(context.Background(), 10, "gpt-5.4") + require.True(t, mapping.Mapped) + require.Equal(t, "gpt-5.4-channel", mapping.MappedModel) + + ctx := WithOpenAIForwardModel( + context.Background(), + mapping.MappedModel, + tt.useCompactModelMapping, + ) + require.False(t, svc.isUpstreamModelRestrictedByChannel( + ctx, 10, account, "gpt-5.4", true, + )) + require.True(t, svc.isUpstreamModelRestrictedByChannel( + context.Background(), 10, account, "gpt-5.4", true, + ), "without the forward-model context the restriction check follows a different chain") + }) + } +} + +func TestIsUpstreamModelRestrictedByChannel_PassthroughMatchesForwardPath(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.4-channel": "gpt-5.4-account"}, + "compact_model_mapping": map[string]any{ + "gpt-5.4-channel": "gpt-5.4-compact", + }, + }, + Extra: map[string]any{"openai_passthrough": true}, + } + tests := []struct { + name string + allowedUpstreamModel string + useCompactModelMapping bool + }{ + { + name: "native v2 keeps channel-mapped model and ignores normal account mapping", + allowedUpstreamModel: "gpt-5.4-channel", + useCompactModelMapping: false, + }, + { + name: "legacy compact applies compact mapping to channel-mapped model", + allowedUpstreamModel: "gpt-5.4-compact", + useCompactModelMapping: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + channelSvc := newTestChannelService(makeStandardRepo(Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + RestrictModels: true, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformOpenAI, Models: []string{tt.allowedUpstreamModel}}, + }, + ModelMapping: map[string]map[string]string{ + PlatformOpenAI: {"gpt-5.4": "gpt-5.4-channel"}, + }, + }, map[int64]string{10: PlatformOpenAI})) + svc := &OpenAIGatewayService{channelService: channelSvc} + mapping := channelSvc.ResolveChannelMapping(context.Background(), 10, "gpt-5.4") + require.True(t, mapping.Mapped) + require.Equal(t, "gpt-5.4-channel", mapping.MappedModel) + + ctx := WithOpenAIForwardModel( + context.Background(), + mapping.MappedModel, + tt.useCompactModelMapping, + ) + require.False(t, svc.isUpstreamModelRestrictedByChannel( + ctx, 10, account, "gpt-5.4", true, + )) + }) + } +} + +func TestIsUpstreamModelRestrictedByChannel_PassthroughFlagWithRawChatFallbackMatchesForwardPath(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.4-channel": "gpt-5.4-account"}, + "compact_model_mapping": map[string]any{ + "gpt-5.4-account": "gpt-5.4-compact", + }, + }, + Extra: map[string]any{ + "openai_passthrough": true, + "openai_responses_supported": false, + }, + } + + for _, useCompactModelMapping := range []bool{false, true} { + useCompactModelMapping := useCompactModelMapping + name := "native v2" + if useCompactModelMapping { + name = "legacy compact" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + + channelSvc := newTestChannelService(makeStandardRepo(Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + RestrictModels: true, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformOpenAI, Models: []string{"gpt-5.4-account"}}, + }, + ModelMapping: map[string]map[string]string{ + PlatformOpenAI: {"gpt-5.4": "gpt-5.4-channel"}, + }, + }, map[int64]string{10: PlatformOpenAI})) + svc := &OpenAIGatewayService{channelService: channelSvc} + ctx := WithOpenAIForwardModel( + context.Background(), + "gpt-5.4-channel", + useCompactModelMapping, + ) + + require.False(t, svc.isUpstreamModelRestrictedByChannel( + ctx, 10, account, "gpt-5.4", true, + )) + }) + } +} + +func TestIsUpstreamModelRestrictedByChannel_ForwardModelContextMatchesNormalForwardPath(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.4-channel": "gpt-5.4-account"}, + }, + Extra: map[string]any{ + "openai_passthrough": true, + "openai_responses_supported": false, + }, + } + channelSvc := newTestChannelService(makeStandardRepo(Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + RestrictModels: true, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformOpenAI, Models: []string{"gpt-5.4-account"}}, + }, + ModelMapping: map[string]map[string]string{ + PlatformOpenAI: {"gpt-5.4": "gpt-5.4-channel"}, + }, + }, map[int64]string{10: PlatformOpenAI})) + svc := &OpenAIGatewayService{channelService: channelSvc} + ctx := WithOpenAIForwardModel(context.Background(), "gpt-5.4-channel", false) + + require.False(t, svc.isUpstreamModelRestrictedByChannel( + ctx, 10, account, "gpt-5.4", false, + )) +} + func TestOpenAISelectAccountForModelWithExclusions_StickyRestrictedUpstreamFallsBack(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_chatgpt_headers.go b/backend/internal/service/openai_chatgpt_headers.go index fe1cd5947e..a9ad42673c 100644 --- a/backend/internal/service/openai_chatgpt_headers.go +++ b/backend/internal/service/openai_chatgpt_headers.go @@ -6,7 +6,7 @@ import ( ) func setOpenAIChatGPTAccountHeaders(headers http.Header, account *Account) { - if headers == nil || account == nil || !account.IsOpenAIOAuth() { + if headers == nil || account == nil || !account.IsOpenAIOAuthLike() { return } if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" { diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go new file mode 100644 index 0000000000..d0e336ec6c --- /dev/null +++ b/backend/internal/service/openai_codex_account_identity.go @@ -0,0 +1,275 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const codexAccountIdentityNamespaceVersion = "v1" + +const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_source" + +// prepareCodexAccountIdentitySource resolves credential shadows once per selected +// attempt. The handler reuses gin.Context across failover attempts, so every entry +// point overwrites the staged source before projecting outbound identity. +func (s *OpenAIGatewayService) prepareCodexAccountIdentitySource(ctx context.Context, c *gin.Context, account *Account) (*Account, error) { + source := account + if account != nil && account.IsShadow() { + resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, err + } + source = resolved + } + if c != nil { + c.Set(codexAccountIdentitySourceContextKey, source) + } + return source, nil +} + +func codexAccountIdentitySource(c *gin.Context, fallback *Account) *Account { + if c != nil { + if staged, ok := c.Get(codexAccountIdentitySourceContextKey); ok { + if source, ok := staged.(*Account); ok && source != nil { + return source + } + } + } + return fallback +} + +// codexAccountIdentityNamespace returns a stable, credential-scoped namespace. +// Multiple local rows that use the same ChatGPT account intentionally share the +// same namespace. Setup tokens use an irreversible bearer fingerprint because +// they have no refresh lifecycle or imported account metadata. Refreshable OAuth +// otherwise falls back only to a persistent fingerprint seed: local row IDs are +// deployment-relative and must never become upstream identity. +func codexAccountIdentityNamespace(account *Account) string { + if account == nil || !account.IsOpenAIOAuthLike() { + return "" + } + if upstreamAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); upstreamAccountID != "" { + if upstreamUserID := strings.TrimSpace(account.GetCredential("chatgpt_user_id")); upstreamUserID != "" { + return "chatgpt:" + upstreamAccountID + ":user:" + upstreamUserID + } + return "chatgpt:" + upstreamAccountID + } + if seed, ok := codexFingerprintSeed(account.Extra); ok { + return "seed:" + seed + } + if account.Type == AccountTypeSetupToken { + if token := strings.TrimSpace(account.GetOpenAIAccessToken()); token != "" { + sum := sha256.Sum256([]byte("openai-setup-token:" + token)) + return fmt.Sprintf("setup-token:%x", sum[:16]) + } + } + return "" +} + +// isolateOpenAIUpstreamSessionID preserves the existing API-key isolation while +// adding the selected OAuth credential namespace. A scheduler failover therefore +// cannot send the same session/conversation identity through two upstream accounts. +func isolateOpenAIUpstreamSessionID(apiKeyID int64, account *Account, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + namespace := codexAccountIdentityNamespace(account) + if namespace == "" { + return isolateOpenAISessionID(apiKeyID, raw) + } + sum := sha256.Sum256([]byte(fmt.Sprintf("u%d:a%s:%s", apiKeyID, namespace, raw))) + return fmt.Sprintf("%x", sum[:8]) +} + +func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, kind, raw string) string { + raw = strings.TrimSpace(raw) + namespace := codexAccountIdentityNamespace(account) + if raw == "" || namespace == "" { + return raw + } + return deriveStableUUIDv4(fmt.Sprintf( + "sub2api:codex-account-identity:%s:user:%d:account:%s:kind:%s:value:%s", + codexAccountIdentityNamespaceVersion, + apiKeyID, + namespace, + kind, + raw, + )) +} + +var codexAccountIdentityFields = []struct { + name string + kind string +}{ + {name: "installation_id", kind: "installation"}, + {name: "x-codex-installation-id", kind: "installation"}, + {name: "session_id", kind: "session"}, + {name: "session-id", kind: "session"}, + {name: "thread_id", kind: "thread"}, + {name: "thread-id", kind: "thread"}, + {name: "turn_id", kind: "turn"}, + {name: "turn-id", kind: "turn"}, + {name: "window_id", kind: "window"}, + {name: "x-codex-window-id", kind: "window"}, + {name: "x-client-request-id", kind: "request"}, +} + +func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool { + if values == nil || codexAccountIdentityNamespace(account) == "" { + return false + } + changed := false + for _, field := range codexAccountIdentityFields { + raw, ok := values[field.name].(string) + if !ok || strings.TrimSpace(raw) == "" { + continue + } + next := scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw) + if next != raw { + values[field.name] = next + changed = true + } + } + return changed +} + +func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64) bool { + raw, ok := values[openAIWSTurnMetadataHeader].(string) + if !ok || strings.TrimSpace(raw) == "" { + return false + } + metadata := map[string]any{} + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + return false + } + if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + return false + } + rebuilt, err := json.Marshal(metadata) + if err != nil { + return false + } + values[openAIWSTurnMetadataHeader] = string(rebuilt) + return true +} + +func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64) bool { + if requestBody == nil || codexAccountIdentityNamespace(account) == "" { + return false + } + changed := false + clientMetadata, _ := requestBody["client_metadata"].(map[string]any) + originalBodySessionID := "" + if clientMetadata != nil { + originalBodySessionID, _ = clientMetadata["session_id"].(string) + if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) { + changed = true + } + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + changed = true + } + } + if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" { + kind := "prompt-cache" + if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { + kind = "session" + } + next := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + if next != raw { + requestBody["prompt_cache_key"] = next + changed = true + } + } + return changed +} + +// applyCodexAccountIdentityClientMetadataRaw scopes only the small identity +// subobjects with gjson/sjson. The passthrough hot path never unmarshals the +// potentially multi-megabyte request body. +func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64) ([]byte, bool, error) { + if len(body) == 0 || codexAccountIdentityNamespace(account) == "" { + return body, false, nil + } + root := gjson.ParseBytes(body) + if !root.IsObject() { + return body, false, nil + } + + next := body + changed := false + originalBodySessionID := "" + if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() { + clientMetadata := map[string]any{} + if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil { + return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err) + } + originalBodySessionID, _ = clientMetadata["session_id"].(string) + metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + metadataChanged = true + } + if metadataChanged { + raw, err := json.Marshal(clientMetadata) + if err != nil { + return body, false, fmt.Errorf("encode account-scoped client_metadata: %w", err) + } + var setErr error + next, setErr = sjson.SetRawBytes(next, "client_metadata", raw) + if setErr != nil { + return body, false, fmt.Errorf("splice account-scoped client_metadata: %w", setErr) + } + changed = true + } + } + if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" { + raw := promptCacheKey.String() + kind := "prompt-cache" + if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { + kind = "session" + } + scoped := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + if scoped != raw { + rewritten, err := sjson.SetBytes(next, "prompt_cache_key", scoped) + if err != nil { + return body, false, fmt.Errorf("splice account-scoped prompt_cache_key: %w", err) + } + next = rewritten + changed = true + } + } + return next, changed, nil +} + +func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64) { + if headers == nil || codexAccountIdentityNamespace(account) == "" { + return + } + for _, field := range codexAccountIdentityFields { + // Underscore session/conversation headers are rebuilt separately from the + // prompt cache key by each request builder. + if field.name == "session_id" { + continue + } + raw := strings.TrimSpace(headers.Get(field.name)) + if raw != "" { + headers.Set(field.name, scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw)) + } + } + if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { + metadata := map[string]any{} + if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + if rebuilt, err := json.Marshal(metadata); err == nil { + headers.Set(openAIWSTurnMetadataHeader, string(rebuilt)) + } + } + } +} diff --git a/backend/internal/service/openai_codex_account_identity_test.go b/backend/internal/service/openai_codex_account_identity_test.go new file mode 100644 index 0000000000..ffceba2d39 --- /dev/null +++ b/backend/internal/service/openai_codex_account_identity_test.go @@ -0,0 +1,230 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type codexAccountIdentityRepoStub struct { + AccountRepository + account *Account +} + +func (s *codexAccountIdentityRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) { + return s.account, nil +} + +func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-codex","prompt_cache_key":"client-session","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session","thread_id":"client-thread","x-codex-window-id":"client-window","x-codex-turn-metadata":"{\"installation_id\":\"client-installation\",\"session_id\":\"client-session\",\"thread_id\":\"client-thread\",\"turn_id\":\"client-turn\",\"window_id\":\"client-window\"}"}}`) + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + + first, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) + require.NoError(t, err) + require.True(t, changed) + firstAgain, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) + require.NoError(t, err) + require.True(t, changed) + second, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account19, 77) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, string(first), string(firstAgain)) + + paths := []string{ + "prompt_cache_key", + "client_metadata.x-codex-installation-id", + "client_metadata.session_id", + "client_metadata.thread_id", + "client_metadata.x-codex-window-id", + } + for _, path := range paths { + require.NotEqual(t, gjson.GetBytes(body, path).String(), gjson.GetBytes(first, path).String(), path) + require.NotEqual(t, gjson.GetBytes(first, path).String(), gjson.GetBytes(second, path).String(), path) + } + require.Equal(t, gjson.GetBytes(first, "prompt_cache_key").String(), gjson.GetBytes(first, "client_metadata.session_id").String()) + + var embeddedFirst map[string]any + var embeddedSecond map[string]any + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(first, "client_metadata.x-codex-turn-metadata").String()), &embeddedFirst)) + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(second, "client_metadata.x-codex-turn-metadata").String()), &embeddedSecond)) + for _, field := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id"} { + require.NotEqual(t, embeddedFirst[field], embeddedSecond[field], field) + } +} + +func TestCodexAccountIdentityNamespaceUsesStableCredentialSource(t *testing.T) { + firstRow := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}} + secondRow := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}} + require.Equal(t, codexAccountIdentityNamespace(firstRow), codexAccountIdentityNamespace(secondRow)) + + firstUser := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}} + sameUser := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}} + secondUser := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-2"}} + require.Equal(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(sameUser)) + require.NotEqual(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(secondUser)) + + seed := "11111111-1111-4111-8111-111111111111" + seeded := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}} + require.Equal(t, "seed:"+seed, codexAccountIdentityNamespace(seeded)) + + // Local row IDs repeat across independent deployments, so they are not a + // safe fallback for upstream identity. + require.Empty(t, codexAccountIdentityNamespace(&Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) + + setupTokenA := &Account{ID: 30, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}} + setupTokenADuplicate := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}} + setupTokenB := &Account{ID: 32, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-b"}} + setupNamespace := codexAccountIdentityNamespace(setupTokenA) + require.NotEmpty(t, setupNamespace) + require.NotContains(t, setupNamespace, "setup-token-a") + require.Equal(t, setupNamespace, codexAccountIdentityNamespace(setupTokenADuplicate)) + require.NotEqual(t, setupNamespace, codexAccountIdentityNamespace(setupTokenB)) +} + +func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + parentID := int64(11) + parent := &Account{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ + "chatgpt_account_id": "team-account", + "chatgpt_user_id": "user-1", + }} + shadow := &Account{ID: 111, ParentAccountID: &parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + service := &OpenAIGatewayService{accountRepo: &codexAccountIdentityRepoStub{account: parent}} + + resolved, err := service.prepareCodexAccountIdentitySource(context.Background(), c, shadow) + require.NoError(t, err) + require.Same(t, parent, resolved) + require.Same(t, parent, codexAccountIdentitySource(c, shadow)) + + req, err := service.buildUpstreamRequest( + context.Background(), c, shadow, + []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + "token", true, "client-session", true, + ) + require.NoError(t, err) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, parent, "client-session"), req.Header.Get("session_id")) + + next := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ + "chatgpt_account_id": "other-account", + "chatgpt_user_id": "user-2", + }} + resolved, err = service.prepareCodexAccountIdentitySource(context.Background(), c, next) + require.NoError(t, err) + require.Same(t, next, resolved) + require.Same(t, next, codexAccountIdentitySource(c, shadow)) +} + +func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Set("api_key_id", int64(77)) + c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-codex-window-id", "client-window") + c.Request.Header.Set("x-client-request-id", "client-request") + + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + service := &OpenAIGatewayService{} + build := func(account *Account) http.Header { + headers, _, err := service.buildOpenAIWSHeaders( + context.Background(), c, account, "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, "", "", "client-session", "", "", + ) + require.NoError(t, err) + return headers + } + + first := build(account11) + firstAgain := build(account11) + second := build(account19) + for _, header := range []string{"session_id", "x-codex-installation-id", "thread-id", "x-codex-window-id", "x-client-request-id"} { + require.NotEmpty(t, first.Get(header), header) + require.Equal(t, first.Get(header), firstAgain.Get(header), header) + require.NotEqual(t, first.Get(header), second.Get(header), header) + } + + httpRequest, err := service.buildUpstreamRequest( + context.Background(), c, account11, + []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + "token", true, "client-session", true, + ) + require.NoError(t, err) + require.Equal(t, httpRequest.Header.Get("session_id"), first.Get("session_id"), "HTTP and WS must derive the same identity from the raw client key") +} + +func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{} + body := []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`) + + build := func(accountID int64, chatgptAccountID string) http.Header { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 77}) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.0") + c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("x-codex-window-id", "client-window") + c.Request.Header.Set("session-id", "client-session") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-client-request-id", "client-request") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"client-installation","session_id":"client-session","thread_id":"client-thread","turn_id":"client-turn","window_id":"client-window"}`) + + account := &Account{ + ID: accountID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": chatgptAccountID, + }, + } + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "oauth-token", true, "client-session", true, + ) + require.NoError(t, err) + return req.Header + } + + first := build(11, "chatgpt-account-11") + firstAgain := build(11, "chatgpt-account-11") + second := build(19, "chatgpt-account-19") + + identityHeaders := []string{ + "x-codex-installation-id", + "x-codex-window-id", + "session-id", + "session_id", + "conversation_id", + "thread-id", + "x-client-request-id", + "x-codex-turn-metadata", + } + checked := 0 + for _, header := range identityHeaders { + if first.Get(header) == "" && second.Get(header) == "" { + continue + } + checked++ + require.NotEmpty(t, first.Get(header), header) + require.Equal(t, first.Get(header), firstAgain.Get(header), "same account must retain stable identity: %s", header) + require.NotEqual(t, first.Get(header), second.Get(header), "account failover must rotate upstream identity: %s", header) + } + require.GreaterOrEqual(t, checked, 5, "test must exercise the real outbound identity surface") +} diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 55564c534c..1e19c07bda 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -5,13 +5,58 @@ import ( "encoding/binary" "encoding/json" "fmt" + "maps" "net/http" "strings" "time" + "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) +// codexFingerprintIDsContextKey 是暂存在 gin context 的收敛 ID 集合键。 +// 由 Forward(非透传)或 forwardOpenAIPassthrough(透传)解析后写入,请求 +// 构造器读取用于出站头改写——请求体与出站头必须共享同一份 IDs,保证 +// turn_id 等随机字段一致。 +const codexFingerprintIDsContextKey = "codex_fingerprint_ids" + +// stageCodexFingerprintIDs 将本 attempt 解析出的收敛 ID 暂存到 gin context。 +// 必须无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一账号的 +// IDs 不得残留并被误应用到新账号的出站头(typed-nil 由应用侧 nil 守卫吸收)。 +func stageCodexFingerprintIDs(c *gin.Context, ids *codexFingerprintIDs) { + if c != nil { + c.Set(codexFingerprintIDsContextKey, ids) + } +} + +func stagedCodexFingerprintIDs(c *gin.Context, account *Account) *codexFingerprintIDs { + if c == nil || account == nil || !account.UsesOpenAICodexProtocol() { + return nil + } + value, ok := c.Get(codexFingerprintIDsContextKey) + if !ok { + return nil + } + ids, ok := value.(*codexFingerprintIDs) + if !ok || ids == nil || ids.accountID != account.ID { + return nil + } + return ids +} + +// applyStagedCodexFingerprintHeaders 读取 context 暂存的收敛 ID 并改写出站头。 +// 非透传与透传两个请求构造器共用本函数,防止应用语义漂移。仅解析该 +// snapshot 的 OAuth 账号可读取,避免 stale context 跨账号 failover 泄漏。 +func applyStagedCodexFingerprintHeaders(c *gin.Context, account *Account, h http.Header) { + applyCodexFingerprintHeaders(h, stagedCodexFingerprintIDs(c, account)) +} + +func applyStagedCodexFingerprintClientMetadata(c *gin.Context, account *Account, reqBody map[string]any) bool { + return applyCodexFingerprintClientMetadata(reqBody, stagedCodexFingerprintIDs(c, account)) +} + // codexFingerprintMode 控制 OAuth 账号出站请求的设备指纹收敛强度。 // 多人共享同一 OAuth 账号时,每个用户的 Codex 客户端会携带各自不同的 // installation_id / session_id / thread_id,上游据此判定设备数和会话数。 @@ -19,7 +64,8 @@ import ( type codexFingerprintMode string const ( - // codexFingerprintOff 不做任何收敛,原样透传客户端标识(默认行为)。 + // codexFingerprintOff 不做任何收敛,原样透传客户端标识。 + // 这是默认值:收敛是显式 opt-in 的(见 GetCodexFingerprintMode)。 codexFingerprintOff codexFingerprintMode = "off" // codexFingerprintDevice 仅收敛 installation_id 为账号级恒定值。 // 上游看到 1 台设备 + 多会话(每用户各自的 session)。 @@ -33,23 +79,136 @@ const ( codexFingerprintFull codexFingerprintMode = "full" ) -const codexFingerprintModeExtraKey = "codex_fingerprint_mode" +const ( + codexFingerprintModeExtraKey = "codex_fingerprint_mode" + codexFingerprintSeedExtraKey = "codex_fingerprint_seed" +) -// GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。 -// 未设置时默认 session(设备+会话收敛),显式设为 "off" 才关闭。 -func (a *Account) GetCodexFingerprintMode() codexFingerprintMode { - if a == nil || !a.IsOpenAIOAuth() { +func canonicalCodexFingerprintSeed(value any) (string, bool) { + raw, ok := value.(string) + if !ok { + return "", false + } + trimmed := strings.TrimSpace(raw) + parsed, err := uuid.Parse(trimmed) + if err != nil || parsed == uuid.Nil || trimmed != parsed.String() { + return "", false + } + return trimmed, true +} + +func newCodexFingerprintSeed() string { + return uuid.NewString() +} + +func stripCodexFingerprintSeed(extra map[string]any) map[string]any { + if extra == nil { + return nil + } + stripped := maps.Clone(extra) + delete(stripped, codexFingerprintSeedExtraKey) + return stripped +} + +func codexFingerprintModeFromExtra(extra map[string]any) codexFingerprintMode { + if extra == nil { return codexFingerprintOff } - raw := strings.TrimSpace(a.GetExtraString(codexFingerprintModeExtraKey)) - switch codexFingerprintMode(raw) { + raw, _ := extra[codexFingerprintModeExtraKey].(string) + switch codexFingerprintMode(strings.TrimSpace(raw)) { case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: - return codexFingerprintMode(raw) + return codexFingerprintMode(strings.TrimSpace(raw)) default: - return codexFingerprintSession + return codexFingerprintOff } } +func codexFingerprintModeRequiresSeed(mode codexFingerprintMode) bool { + switch mode { + case codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: + return true + default: + return false + } +} + +func codexFingerprintSeed(extra map[string]any) (string, bool) { + if extra == nil { + return "", false + } + return canonicalCodexFingerprintSeed(extra[codexFingerprintSeedExtraKey]) +} + +func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra map[string]any) map[string]any { + prepared := stripCodexFingerprintSeed(extra) + if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) { + return prepared + } + if prepared == nil { + prepared = make(map[string]any, 1) + } + prepared[codexFingerprintSeedExtraKey] = newCodexFingerprintSeed() + return prepared +} + +func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]any) map[string]any { + prepared := stripCodexFingerprintSeed(extra) + if account == nil || !account.IsOpenAIOAuthLike() { + return prepared + } + if seed, ok := codexFingerprintSeed(account.Extra); ok { + if prepared == nil { + prepared = make(map[string]any, 1) + } + prepared[codexFingerprintSeedExtraKey] = seed + return prepared + } + if codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) { + if prepared == nil { + prepared = make(map[string]any, 1) + } + prepared[codexFingerprintSeedExtraKey] = newCodexFingerprintSeed() + } + return prepared +} + +func sanitizedCodexFingerprintExtraUpdates(updates map[string]any) map[string]any { + if updates == nil { + return nil + } + sanitized := maps.Clone(updates) + delete(sanitized, codexFingerprintSeedExtraKey) + return sanitized +} + +// ShouldEnsureCodexFingerprintSeedForExtraUpdates reports whether a JSONB key-level +// extra update is enabling Codex fingerprint convergence and therefore must atomically +// preserve or create the system-managed per-account seed in the repository update. +func ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates map[string]any) bool { + if updates == nil { + return false + } + return codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(updates)) +} + +// GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。 +// +// **收敛是显式 opt-in**:未设置、空值或非法值一律按 off 处理,只有管理员 +// 明确配置 device / session / full 才收敛。 +// +// 历史:v0.1.175(#5553)把缺省值当作 session,导致升级后存量 OAuth 账号 +// (普遍没有这个 extra 键)的每个非透传请求都被静默改写 installation / +// session / thread / turn / window 五类标识;#5555、#5556、#5582 报告的额度 +// 缩水都卡在该版本边界,并有"回退 v0.1.173 即恢复"与"新账号开收敛后降额" +// 的 A/B 实测。上游的配额判定策略不可观测,因此这里取兼容安全的一侧: +// 不显式 opt-in 就保持 v0.1.175 之前的客户端身份(#5610)。 +func (a *Account) GetCodexFingerprintMode() codexFingerprintMode { + if a == nil || !a.IsOpenAIOAuthLike() { + return codexFingerprintOff + } + return codexFingerprintModeFromExtra(a.Extra) +} + // deriveStableUUIDv4 从种子确定性派生一个 UUIDv4 格式的字符串。 // 同一种子永远返回同一值。 func deriveStableUUIDv4(seed string) string { @@ -66,45 +225,53 @@ func deriveStableUUIDv4(seed string) string { } // resolveConvergedInstallationID 返回账号级恒定的 installation_id。 -// 优先使用管理员配置的真实 device_id,无则从 accountID 确定性派生。 -func resolveConvergedInstallationID(account *Account) string { +// 优先使用管理员配置的真实 device_id,无则从系统管理的账号随机种子确定性派生。 +func resolveConvergedInstallationID(account *Account, seed string) string { if account == nil { return "" } if deviceID := account.GetOpenAIDeviceID(); deviceID != "" { return deviceID } - return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-install-id:v1:%d", account.ID)) + if seed == "" { + return "" + } + return deriveStableUUIDv4("sub2api:codex-install-id:v2:" + seed) } // resolveConvergedSessionID 返回账号级恒定的 session_id。 -func resolveConvergedSessionID(account *Account) string { - if account == nil { +func resolveConvergedSessionID(seed string) string { + if seed == "" { return "" } - return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-session-id:v1:%d", account.ID)) + return deriveStableUUIDv4("sub2api:codex-session-id:v2:" + seed) } // resolveConvergedThreadID 按客户端原始 session-id 确定性派生 thread_id。 // 每个真实 Codex 会话(不同客户端启动实例)获得一个独立线程, // 模拟正常用户 spawn 子代理或开多窗口的模式。 -func resolveConvergedThreadID(account *Account, clientSessionID string) string { - if account == nil || clientSessionID == "" { +func resolveConvergedThreadID(seed, clientSessionID string) string { + if seed == "" || clientSessionID == "" { return "" } - return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-thread-id:v1:%d:%s", account.ID, clientSessionID)) + return deriveStableUUIDv4("sub2api:codex-thread-id:v2:" + seed + ":" + clientSessionID) } // codexFingerprintIDs 收敛后的完整 ID 集合。 // 由 resolveCodexFingerprintIDs 一次性生成,同一个实例在头改写和体改写之间共享, -// 确保所有载体中的 turn_id 等随机字段一致。 +// 确保所有载体中的 turn_id 等随机字段一致。体改写时还会补记原始 +// client_metadata.session_id,用于识别 root prompt_cache_key 的默认值。 type codexFingerprintIDs struct { - mode codexFingerprintMode - installationID string - sessionID string - threadID string - turnID string - windowID string + accountID int64 + mode codexFingerprintMode + installationID string + sessionID string + threadID string + turnID string + windowID string + turnStartedAtUnixMs int64 + originalBodySessionID string + originalBodySessionIDCaptured bool } // resolveCodexFingerprintIDs 按收敛模式计算出站 ID 集合。 @@ -113,13 +280,21 @@ type codexFingerprintIDs struct { // 返回 nil 表示 off 模式,不需要改写。 // 注意:包含随机生成的 turn_id,调用方必须只调用一次并共享结果给头改写和体改写。 func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode codexFingerprintMode) *codexFingerprintIDs { - if mode == codexFingerprintOff { + if account == nil || mode == codexFingerprintOff { + return nil + } + seed, ok := codexFingerprintSeed(account.Extra) + if !ok { return nil } - ids := &codexFingerprintIDs{mode: mode} + ids := &codexFingerprintIDs{ + accountID: account.ID, + mode: mode, + turnStartedAtUnixMs: time.Now().UnixMilli(), + } - ids.installationID = resolveConvergedInstallationID(account) + ids.installationID = resolveConvergedInstallationID(account, seed) if ids.installationID == "" { return nil } @@ -129,8 +304,8 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c return ids case codexFingerprintSession: - ids.sessionID = resolveConvergedSessionID(account) - ids.threadID = resolveConvergedThreadID(account, clientSessionID) + ids.sessionID = resolveConvergedSessionID(seed) + ids.threadID = resolveConvergedThreadID(seed, clientSessionID) if ids.threadID == "" { ids.threadID = ids.sessionID } @@ -139,7 +314,7 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c return ids case codexFingerprintFull: - ids.sessionID = resolveConvergedSessionID(account) + ids.sessionID = resolveConvergedSessionID(seed) ids.threadID = ids.sessionID ids.turnID = uuid.Must(uuid.NewV7()).String() ids.windowID = ids.threadID + ":0" @@ -208,20 +383,21 @@ func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) { "thread_id": ids.threadID, "turn_id": ids.turnID, "window_id": ids.windowID, - "turn_started_at_unix_ms": time.Now().UnixMilli(), + "turn_started_at_unix_ms": ids.turnStartedAtUnixMs, }) } // rewriteCodexTurnMetadataFields 解析 x-codex-turn-metadata 头中的 JSON, -// 替换指定字段后回写。保留未指定字段原样(如 sandbox、thread_source 等)。 +// 替换指定字段后回写。合法对象保留未指定字段(如 sandbox、thread_source); +// 非法/非对象值重建为最小合法 metadata,避免 flat 与 embedded identity 分裂。 func rewriteCodexTurnMetadataFields(h http.Header, fields map[string]any) { raw := strings.TrimSpace(h.Get("x-codex-turn-metadata")) if raw == "" { return } var metadata map[string]any - if err := json.Unmarshal([]byte(raw), &metadata); err != nil { - return + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + metadata = make(map[string]any, len(fields)) } for k, v := range fields { metadata[k] = v @@ -240,11 +416,31 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge return false } + captureCodexFingerprintOriginalBodySessionID(ids, reqBody["client_metadata"]) existing, _ := reqBody["client_metadata"].(map[string]any) if existing == nil { existing = make(map[string]any) } + modified := false + if applyCodexFingerprintToClientMetadataMap(existing, ids) { + reqBody["client_metadata"] = existing + modified = true + } + if applyCodexFingerprintPromptCacheKey(reqBody, ids) { + modified = true + } + return modified +} + +// applyCodexFingerprintToClientMetadataMap 是 client_metadata 改写的共享核心, +// map 版(非透传,body 已解码)与 raw 字节版(透传热路径)都经由它,保证两条 +// 路径的收敛语义永不漂移。 +func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *codexFingerprintIDs) bool { + if existing == nil || ids == nil { + return false + } + modified := false if ids.installationID != "" { @@ -256,9 +452,6 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{ "installation_id": ids.installationID, }) - if modified { - reqBody["client_metadata"] = existing - } return modified } @@ -274,23 +467,130 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge "thread_id": ids.threadID, "turn_id": ids.turnID, "window_id": ids.windowID, - "turn_started_at_unix_ms": time.Now().UnixMilli(), + "turn_started_at_unix_ms": ids.turnStartedAtUnixMs, }) - - reqBody["client_metadata"] = existing return true } +func captureCodexFingerprintOriginalBodySessionID(ids *codexFingerprintIDs, clientMetadata any) { + if ids == nil || ids.originalBodySessionIDCaptured { + return + } + ids.originalBodySessionIDCaptured = true + if clientMetadata == nil { + return + } + switch metadata := clientMetadata.(type) { + case map[string]any: + if sessionID, ok := metadata["session_id"].(string); ok { + ids.originalBodySessionID = strings.TrimSpace(sessionID) + } + case map[string]string: + ids.originalBodySessionID = strings.TrimSpace(metadata["session_id"]) + } +} + +func captureCodexFingerprintOriginalBodySessionIDRaw(ids *codexFingerprintIDs, value gjson.Result) { + if ids == nil || ids.originalBodySessionIDCaptured { + return + } + ids.originalBodySessionIDCaptured = true + if value.Exists() && value.Type == gjson.String { + ids.originalBodySessionID = strings.TrimSpace(value.String()) + } +} + +func shouldRewriteCodexFingerprintPromptCacheKey(ids *codexFingerprintIDs, promptCacheKey string) bool { + if ids == nil || !ids.originalBodySessionIDCaptured || ids.originalBodySessionID == "" || ids.sessionID == "" { + return false + } + if ids.mode != codexFingerprintSession && ids.mode != codexFingerprintFull { + return false + } + return promptCacheKey == ids.originalBodySessionID +} + +func applyCodexFingerprintPromptCacheKey(reqBody map[string]any, ids *codexFingerprintIDs) bool { + if reqBody == nil { + return false + } + promptCacheKey, ok := reqBody["prompt_cache_key"].(string) + if !ok || strings.TrimSpace(promptCacheKey) == "" || !shouldRewriteCodexFingerprintPromptCacheKey(ids, promptCacheKey) { + return false + } + if promptCacheKey == ids.sessionID { + return false + } + reqBody["prompt_cache_key"] = ids.sessionID + return true +} + +// applyCodexFingerprintClientMetadataRaw 在原始 JSON 字节上改写 client_metadata, +// 供透传路径使用——透传是热路径,禁止对可能高达数十 MB 的 body 做全量 +// Unmarshal(见 forwardOpenAIPassthrough 的轻量提取注释)。实现为:gjson 提取 +// client_metadata 小对象单独解码,经共享核心改写后 sjson 一次性拼回,body +// 其余字节原样保留;root prompt_cache_key 仅在可证明是 body session 默认值时 +// 做标量改写。语义与 applyCodexFingerprintClientMetadata 逐点一致(含 +// "非对象值整体替换为收敛集合"的行为)。 +func applyCodexFingerprintClientMetadataRaw(body []byte, ids *codexFingerprintIDs) ([]byte, bool, error) { + if len(body) == 0 || ids == nil { + return body, false, nil + } + // 非 JSON 对象的 body(数组/标量/畸形)没有 client_metadata 语义, + // sjson 在这类根上写字段会改写整体结构,直接放行保持原样。 + root := gjson.ParseBytes(body) + if !root.IsObject() { + captureCodexFingerprintOriginalBodySessionIDRaw(ids, gjson.Result{}) + return body, false, nil + } + + existing := map[string]any{} + if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() { + captureCodexFingerprintOriginalBodySessionIDRaw(ids, gjson.GetBytes(body, "client_metadata.session_id")) + if err := json.Unmarshal([]byte(cm.Raw), &existing); err != nil { + return body, false, fmt.Errorf("decode client_metadata for fingerprint: %w", err) + } + } else { + captureCodexFingerprintOriginalBodySessionIDRaw(ids, gjson.Result{}) + } + + next := body + modified := false + if applyCodexFingerprintToClientMetadataMap(existing, ids) { + raw, err := json.Marshal(existing) + if err != nil { + return body, false, fmt.Errorf("encode converged client_metadata: %w", err) + } + var setErr error + next, setErr = sjson.SetRawBytes(body, "client_metadata", raw) + if setErr != nil { + return body, false, fmt.Errorf("splice converged client_metadata: %w", setErr) + } + modified = true + } + promptCacheKey := gjson.GetBytes(body, "prompt_cache_key") + if promptCacheKey.Exists() && promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" && shouldRewriteCodexFingerprintPromptCacheKey(ids, promptCacheKey.String()) { + rewritten, err := sjson.SetBytes(next, "prompt_cache_key", ids.sessionID) + if err != nil { + return body, false, fmt.Errorf("splice converged prompt_cache_key: %w", err) + } + next = rewritten + modified = true + } + return next, modified, nil +} + // rewriteClientMetadataEmbeddedTurnMetadata 改写 client_metadata 中内嵌的 -// x-codex-turn-metadata JSON 字符串里的指定字段。 +// x-codex-turn-metadata JSON 字符串里的指定字段。非法/非对象值会重建, +// 避免 flat client_metadata 与 embedded metadata 暴露两套身份。 func rewriteClientMetadataEmbeddedTurnMetadata(clientMetadata map[string]any, fields map[string]any) { raw, ok := clientMetadata["x-codex-turn-metadata"].(string) if !ok || raw == "" { return } var metadata map[string]any - if err := json.Unmarshal([]byte(raw), &metadata); err != nil { - return + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + metadata = make(map[string]any, len(fields)) } for k, v := range fields { metadata[k] = v diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go index 039f843c2c..1fdd06de1f 100644 --- a/backend/internal/service/openai_codex_fingerprint_test.go +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -1,16 +1,29 @@ package service import ( + "context" "encoding/json" "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const testCodexFingerprintSeed = "11111111-1111-4111-8111-111111111111" + func newTestOAuthAccount(id int64, extra map[string]any) *Account { + if codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(extra)) { + if extra == nil { + extra = make(map[string]any) + } + if _, exists := extra[codexFingerprintSeedExtraKey]; !exists { + extra[codexFingerprintSeedExtraKey] = testCodexFingerprintSeed + } + } return &Account{ ID: id, Platform: PlatformOpenAI, @@ -51,9 +64,13 @@ func TestGetCodexFingerprintMode(t *testing.T) { }{ {"nil 账号", nil, codexFingerprintOff}, {"非 OAuth 账号", &Account{Platform: PlatformOpenAI, Type: "api_key"}, codexFingerprintOff}, - {"无 extra 默认 session", newTestOAuthAccount(1, nil), codexFingerprintSession}, - {"空值默认 session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: ""}), codexFingerprintSession}, - {"非法值默认 session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "invalid"}), codexFingerprintSession}, + {"OpenAI setup token", &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintSession}, + {"Anthropic setup token", &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintOff}, + // 收敛是显式 opt-in:缺省/空/非法一律 off(#5610)。存量账号普遍没有这个 + // extra 键,升级不得把它们静默切进收敛。 + {"无 extra 默认 off", newTestOAuthAccount(1, nil), codexFingerprintOff}, + {"空值默认 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: ""}), codexFingerprintOff}, + {"非法值默认 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "invalid"}), codexFingerprintOff}, {"显式 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "off"}), codexFingerprintOff}, {"device", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "device"}), codexFingerprintDevice}, {"session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "session"}), codexFingerprintSession}, @@ -70,42 +87,40 @@ func TestGetCodexFingerprintMode(t *testing.T) { func TestResolveConvergedInstallationID_UsesDeviceID(t *testing.T) { account := newTestOAuthAccount(1, map[string]any{"openai_device_id": "real-device-id"}) - assert.Equal(t, "real-device-id", resolveConvergedInstallationID(account)) + assert.Equal(t, "real-device-id", resolveConvergedInstallationID(account, testCodexFingerprintSeed)) } -func TestResolveConvergedInstallationID_DerivesFromAccountID(t *testing.T) { +func TestResolveConvergedInstallationID_DerivesFromSeed(t *testing.T) { account := newTestOAuthAccount(42, nil) - result := resolveConvergedInstallationID(account) + result := resolveConvergedInstallationID(account, testCodexFingerprintSeed) _, err := uuid.Parse(result) require.NoError(t, err, "派生值应为合法 UUID") - assert.Equal(t, result, resolveConvergedInstallationID(account), "确定性") + assert.Equal(t, result, resolveConvergedInstallationID(account, testCodexFingerprintSeed), "确定性") } -func TestResolveConvergedInstallationID_DifferentAccounts(t *testing.T) { - a := resolveConvergedInstallationID(newTestOAuthAccount(1, nil)) - b := resolveConvergedInstallationID(newTestOAuthAccount(2, nil)) +func TestResolveConvergedInstallationID_DifferentSeeds(t *testing.T) { + account := newTestOAuthAccount(1, nil) + a := resolveConvergedInstallationID(account, testCodexFingerprintSeed) + b := resolveConvergedInstallationID(account, "22222222-2222-4222-8222-222222222222") assert.NotEqual(t, a, b) } // --- resolveConvergedThreadID --- func TestResolveConvergedThreadID_PerClientSession(t *testing.T) { - account := newTestOAuthAccount(1, nil) - a := resolveConvergedThreadID(account, "session-aaa") - b := resolveConvergedThreadID(account, "session-bbb") + a := resolveConvergedThreadID(testCodexFingerprintSeed, "session-aaa") + b := resolveConvergedThreadID(testCodexFingerprintSeed, "session-bbb") assert.NotEqual(t, a, b, "不同客户端 session 应得到不同 thread_id") } func TestResolveConvergedThreadID_Deterministic(t *testing.T) { - account := newTestOAuthAccount(1, nil) - a := resolveConvergedThreadID(account, "session-aaa") - b := resolveConvergedThreadID(account, "session-aaa") + a := resolveConvergedThreadID(testCodexFingerprintSeed, "session-aaa") + b := resolveConvergedThreadID(testCodexFingerprintSeed, "session-aaa") assert.Equal(t, a, b, "同一客户端 session 应得到相同 thread_id") } func TestResolveConvergedThreadID_EmptySession(t *testing.T) { - account := newTestOAuthAccount(1, nil) - assert.Equal(t, "", resolveConvergedThreadID(account, "")) + assert.Equal(t, "", resolveConvergedThreadID(testCodexFingerprintSeed, "")) } // --- off 模式:resolveCodexFingerprintIDsFromRequest 返回 nil --- @@ -116,13 +131,43 @@ func TestResolveCodexFingerprintIDsFromRequest_ExplicitOff(t *testing.T) { assert.Nil(t, ids, "显式 off 模式应返回 nil") } -func TestResolveCodexFingerprintIDsFromRequest_DefaultIsSession(t *testing.T) { +// 未显式配置的存量账号不得被收敛(#5610):默认返回 nil,出站身份保持 +// v0.1.175 之前的客户端原值。 +func TestResolveCodexFingerprintIDsFromRequest_DefaultIsOff(t *testing.T) { account := newTestOAuthAccount(1, nil) - ids := resolveCodexFingerprintIDsFromRequest(account, nil) - require.NotNil(t, ids, "无 extra 默认 session 模式,应返回非 nil") - assert.Equal(t, codexFingerprintSession, ids.mode) - assert.NotEmpty(t, ids.sessionID) - assert.NotEmpty(t, ids.turnID) + assert.Nil(t, resolveCodexFingerprintIDsFromRequest(account, nil), "无 extra 应视为 off") +} + +// 管理员显式 opt-in 的账号行为不变。 +func TestResolveCodexFingerprintIDsFromRequest_ExplicitOptInHonored(t *testing.T) { + for _, mode := range []string{"device", "session", "full"} { + t.Run(mode, func(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: mode}) + ids := resolveCodexFingerprintIDsFromRequest(account, nil) + require.NotNil(t, ids, "显式配置必须生效") + assert.Equal(t, codexFingerprintMode(mode), ids.mode) + assert.NotEmpty(t, ids.installationID) + }) + } +} + +func TestResolveCodexFingerprintIDsFromRequest_EnabledModesRequireValidSeed(t *testing.T) { + for _, tt := range []struct { + name string + extra map[string]any + }{ + {name: "missing", extra: map[string]any{codexFingerprintModeExtraKey: "device"}}, + {name: "missing with device override", extra: map[string]any{codexFingerprintModeExtraKey: "device", "openai_device_id": "real-device"}}, + {name: "blank", extra: map[string]any{codexFingerprintModeExtraKey: "session", codexFingerprintSeedExtraKey: ""}}, + {name: "uppercase", extra: map[string]any{codexFingerprintModeExtraKey: "full", codexFingerprintSeedExtraKey: "11111111-1111-4111-8111-AAAAAAAAAAAA"}}, + {name: "nil uuid", extra: map[string]any{codexFingerprintModeExtraKey: "device", codexFingerprintSeedExtraKey: "00000000-0000-0000-0000-000000000000"}}, + {name: "non string", extra: map[string]any{codexFingerprintModeExtraKey: "session", codexFingerprintSeedExtraKey: 123}}, + } { + t.Run(tt.name, func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: tt.extra} + require.Nil(t, resolveCodexFingerprintIDsFromRequest(account, nil)) + }) + } } // --- applyCodexFingerprintHeaders: off 模式 --- @@ -183,9 +228,11 @@ func TestApplyCodexFingerprintHeaders_SessionMode(t *testing.T) { ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) applyCodexFingerprintHeaders(h, ids) - convergedInstall := resolveConvergedInstallationID(account) - convergedSession := resolveConvergedSessionID(account) - convergedThread := resolveConvergedThreadID(account, "client-session-aaa") + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedInstall := resolveConvergedInstallationID(account, seed) + convergedSession := resolveConvergedSessionID(seed) + convergedThread := resolveConvergedThreadID(seed, "client-session-aaa") assert.Equal(t, convergedInstall, h.Get("x-codex-installation-id")) assert.Equal(t, convergedSession, h.Get("session-id")) @@ -241,7 +288,9 @@ func TestApplyCodexFingerprintHeaders_FullMode(t *testing.T) { account := newTestOAuthAccount(1, map[string]any{ codexFingerprintModeExtraKey: "full", }) - convergedSession := resolveConvergedSessionID(account) + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedSession := resolveConvergedSessionID(seed) clientA := http.Header{} clientA.Set("session-id", "client-A") @@ -313,6 +362,41 @@ func TestFingerprintIDs_HeaderAndBody_TurnID_Consistent(t *testing.T) { assert.Equal(t, headerTurnID, bodyTurnID, "头和体的 turn_id 必须一致") assert.Equal(t, headerTurnID, bodyEmbeddedTurnID, "头和体内嵌 turn-metadata 的 turn_id 必须一致") assert.Equal(t, ids.turnID, headerTurnID, "所有 turn_id 都应来自同一份 ids") + assert.Equal(t, headerMeta["turn_started_at_unix_ms"], bodyMeta["turn_started_at_unix_ms"], "头和体的 timestamp 必须一致") + assert.Equal(t, float64(ids.turnStartedAtUnixMs), headerMeta["turn_started_at_unix_ms"]) +} + +func TestFingerprintIDs_MalformedEmbeddedMetadataRebuiltConsistently(t *testing.T) { + account := newTestOAuthAccount(2, map[string]any{codexFingerprintModeExtraKey: "session"}) + clientHeaders := make(http.Header) + clientHeaders.Set("session-id", "client-session-malformed") + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + require.NotNil(t, ids) + + h := make(http.Header) + h.Set("x-codex-turn-metadata", "{malformed") + applyCodexFingerprintHeaders(h, ids) + + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "session_id": "client-session-malformed", + "x-codex-turn-metadata": "[malformed", + }, + } + require.True(t, applyCodexFingerprintClientMetadata(reqBody, ids)) + + var headerMeta map[string]any + require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &headerMeta)) + clientMetadata, ok := reqBody["client_metadata"].(map[string]any) + require.True(t, ok) + bodyRaw, ok := clientMetadata["x-codex-turn-metadata"].(string) + require.True(t, ok) + var bodyMeta map[string]any + require.NoError(t, json.Unmarshal([]byte(bodyRaw), &bodyMeta)) + + for _, key := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id", "turn_started_at_unix_ms"} { + assert.Equal(t, headerMeta[key], bodyMeta[key], "rebuilt metadata field %s must match", key) + } } // --- applyCodexFingerprintClientMetadata --- @@ -384,9 +468,11 @@ func TestApplyCodexFingerprintClientMetadata_SessionMode(t *testing.T) { cm, ok := reqBody["client_metadata"].(map[string]any) require.True(t, ok) - convergedInstall := resolveConvergedInstallationID(account) - convergedSession := resolveConvergedSessionID(account) - convergedThread := resolveConvergedThreadID(account, "client-session-aaa") + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedInstall := resolveConvergedInstallationID(account, seed) + convergedSession := resolveConvergedSessionID(seed) + convergedThread := resolveConvergedThreadID(seed, "client-session-aaa") assert.Equal(t, convergedInstall, cm["x-codex-installation-id"]) assert.Equal(t, convergedSession, cm["session_id"]) @@ -425,7 +511,9 @@ func TestApplyCodexFingerprintClientMetadata_FullMode(t *testing.T) { cm, ok := reqBody["client_metadata"].(map[string]any) require.True(t, ok) - convergedSession := resolveConvergedSessionID(account) + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedSession := resolveConvergedSessionID(seed) assert.Equal(t, convergedSession, cm["session_id"]) assert.Equal(t, convergedSession, cm["thread_id"], "full 模式 thread_id 应等于 session_id") @@ -458,3 +546,385 @@ func TestExtractClientSessionID(t *testing.T) { }) } } + +// --- 透传路径:raw 字节版 client_metadata 改写 --- + +// rawVsMapClientMetadata 用同一份 ids 分别跑 map 版与 raw 字节版, +// 返回两侧最终的 client_metadata 解码结果。 +func rawVsMapClientMetadata(t *testing.T, body []byte, ids *codexFingerprintIDs) (map[string]any, map[string]any) { + t.Helper() + + var decoded map[string]any + require.NoError(t, json.Unmarshal(body, &decoded)) + applyCodexFingerprintClientMetadata(decoded, ids) + mapCM, _ := decoded["client_metadata"].(map[string]any) + + rawBody, changed, err := applyCodexFingerprintClientMetadataRaw(body, ids) + require.NoError(t, err) + require.True(t, changed) + var rawDecoded map[string]any + require.NoError(t, json.Unmarshal(rawBody, &rawDecoded)) + rawCM, _ := rawDecoded["client_metadata"].(map[string]any) + return mapCM, rawCM +} + +func cloneCodexFingerprintIDsForTest(ids *codexFingerprintIDs) *codexFingerprintIDs { + if ids == nil { + return nil + } + cloned := *ids + cloned.originalBodySessionID = "" + cloned.originalBodySessionIDCaptured = false + return &cloned +} + +func applyMapAndRawFingerprintBodiesForTest(t *testing.T, body []byte, ids *codexFingerprintIDs) (map[string]any, map[string]any) { + t.Helper() + + mapIDs := cloneCodexFingerprintIDsForTest(ids) + rawIDs := cloneCodexFingerprintIDsForTest(ids) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(body, &decoded)) + applyCodexFingerprintClientMetadata(decoded, mapIDs) + + rawBody, _, err := applyCodexFingerprintClientMetadataRaw(body, rawIDs) + require.NoError(t, err) + var rawDecoded map[string]any + require.NoError(t, json.Unmarshal(rawBody, &rawDecoded)) + return decoded, rawDecoded +} + +func TestApplyCodexFingerprintPromptCacheKey_MapRawEquivalence(t *testing.T) { + for _, mode := range []codexFingerprintMode{codexFingerprintSession, codexFingerprintFull} { + t.Run(string(mode)+"/default", func(t *testing.T) { + account := newTestOAuthAccount(4300, map[string]any{codexFingerprintModeExtraKey: string(mode)}) + ids := resolveCodexFingerprintIDs(account, "header-session", mode) + require.NotNil(t, ids) + + body := []byte(`{"model":"gpt-5.6-sol","prompt_cache_key":"body-session","client_metadata":{"session_id":" body-session ","trace":"keep"},"input":[]}`) + mapBody, rawBody := applyMapAndRawFingerprintBodiesForTest(t, body, ids) + + require.Equal(t, mapBody["prompt_cache_key"], rawBody["prompt_cache_key"]) + require.Equal(t, ids.sessionID, mapBody["prompt_cache_key"]) + mapCM, _ := mapBody["client_metadata"].(map[string]any) + rawCM, _ := rawBody["client_metadata"].(map[string]any) + require.Equal(t, ids.sessionID, mapCM["session_id"]) + require.Equal(t, mapCM["session_id"], rawCM["session_id"]) + require.Equal(t, "keep", rawCM["trace"]) + }) + } + + t.Run("explicit override", func(t *testing.T) { + account := newTestOAuthAccount(4301, map[string]any{codexFingerprintModeExtraKey: "session"}) + ids := resolveCodexFingerprintIDs(account, "header-session", codexFingerprintSession) + require.NotNil(t, ids) + + body := []byte(`{"model":"gpt-5.6-sol","prompt_cache_key":"explicit-cache","client_metadata":{"session_id":"body-session"},"input":[]}`) + mapBody, rawBody := applyMapAndRawFingerprintBodiesForTest(t, body, ids) + + require.Equal(t, "explicit-cache", mapBody["prompt_cache_key"]) + require.Equal(t, "explicit-cache", rawBody["prompt_cache_key"]) + mapCM, _ := mapBody["client_metadata"].(map[string]any) + rawCM, _ := rawBody["client_metadata"].(map[string]any) + require.Equal(t, ids.sessionID, mapCM["session_id"]) + require.Equal(t, ids.sessionID, rawCM["session_id"]) + }) +} + +func TestApplyCodexFingerprintPromptCacheKey_Negatives(t *testing.T) { + sessionAccount := newTestOAuthAccount(4310, map[string]any{codexFingerprintModeExtraKey: "session"}) + sessionIDs := resolveCodexFingerprintIDs(sessionAccount, "header-session", codexFingerprintSession) + require.NotNil(t, sessionIDs) + deviceAccount := newTestOAuthAccount(4311, map[string]any{codexFingerprintModeExtraKey: "device"}) + deviceIDs := resolveCodexFingerprintIDs(deviceAccount, "header-session", codexFingerprintDevice) + require.NotNil(t, deviceIDs) + + tests := []struct { + name string + body []byte + ids *codexFingerprintIDs + wantExists bool + wantCacheKey any + wantRawString string + }{ + { + name: "missing key is not injected", + body: []byte(`{"client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: false, + }, + { + name: "empty key preserved", + body: []byte(`{"prompt_cache_key":"","client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "", + }, + { + name: "whitespace-different key is an explicit override", + body: []byte(`{"prompt_cache_key":" body-session ","client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: " body-session ", + }, + { + name: "non-string key preserved", + body: []byte(`{"prompt_cache_key":123,"client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: float64(123), + }, + { + name: "missing source metadata preserves key", + body: []byte(`{"prompt_cache_key":"body-session"}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "body-session", + }, + { + name: "non-string source session preserves key", + body: []byte(`{"prompt_cache_key":"123","client_metadata":{"session_id":123}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "123", + }, + { + name: "non-object source metadata preserves key", + body: []byte(`{"prompt_cache_key":"body-session","client_metadata":"bad"}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "body-session", + }, + { + name: "device mode preserves key", + body: []byte(`{"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"}}`), + ids: deviceIDs, + wantExists: true, + wantCacheKey: "body-session", + }, + { + name: "off mode preserves body", + body: []byte(`{"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"}}`), + ids: nil, + wantExists: true, + wantCacheKey: "body-session", + wantRawString: `{"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mapBody map[string]any + require.NoError(t, json.Unmarshal(tt.body, &mapBody)) + changedMap := applyCodexFingerprintClientMetadata(mapBody, cloneCodexFingerprintIDsForTest(tt.ids)) + + rawBody, changedRaw, err := applyCodexFingerprintClientMetadataRaw(tt.body, cloneCodexFingerprintIDsForTest(tt.ids)) + require.NoError(t, err) + if tt.ids == nil { + require.False(t, changedMap) + require.False(t, changedRaw) + require.JSONEq(t, tt.wantRawString, string(rawBody)) + return + } + require.True(t, changedMap) + require.True(t, changedRaw) + + rawDecoded := map[string]any{} + require.NoError(t, json.Unmarshal(rawBody, &rawDecoded)) + _, mapExists := mapBody["prompt_cache_key"] + _, rawExists := rawDecoded["prompt_cache_key"] + require.Equal(t, tt.wantExists, mapExists) + require.Equal(t, tt.wantExists, rawExists) + if tt.wantExists { + require.Equal(t, tt.wantCacheKey, mapBody["prompt_cache_key"]) + require.Equal(t, tt.wantCacheKey, rawDecoded["prompt_cache_key"]) + } + }) + } +} + +func TestApplyCodexFingerprintClientMetadataRaw_MatchesMapVariant(t *testing.T) { + embedded := `{\"installation_id\":\"real-install\",\"session_id\":\"real-session\",\"sandbox\":\"seatbelt\"}` + bodies := map[string]string{ + "no_client_metadata": `{"model":"gpt-5.6-sol","input":[],"stream":true}`, + "object_with_extras": `{"model":"gpt-5.6-sol","client_metadata":{"session_id":"client-session","traceparent":"00-abc-def-01","x-codex-turn-metadata":"` + embedded + `"},"stream":true}`, + "non_object_value": `{"model":"gpt-5.6-sol","client_metadata":"bogus","stream":true}`, + } + for _, mode := range []codexFingerprintMode{codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull} { + account := newTestOAuthAccount(4242, map[string]any{codexFingerprintModeExtraKey: string(mode)}) + ids := resolveCodexFingerprintIDs(account, "client-sess-raw", mode) + require.NotNil(t, ids) + for name, body := range bodies { + t.Run(string(mode)+"/"+name, func(t *testing.T) { + mapCM, rawCM := rawVsMapClientMetadata(t, []byte(body), ids) + assert.Equal(t, mapCM, rawCM, "raw 字节版与 map 版的 client_metadata 结果必须逐点一致") + }) + } + } +} + +func TestApplyCodexFingerprintClientMetadataRaw_PreservesUnrelatedFields(t *testing.T) { + account := newTestOAuthAccount(4243, map[string]any{codexFingerprintModeExtraKey: "session"}) + ids := resolveCodexFingerprintIDs(account, "client-sess-preserve", codexFingerprintSession) + require.NotNil(t, ids) + + body := []byte(`{"model":"gpt-5.6-sol","input":[{"type":"message","role":"user","content":"hi"}],"stream":true,"prompt_cache_key":"pck-1"}`) + out, changed, err := applyCodexFingerprintClientMetadataRaw(body, ids) + require.NoError(t, err) + require.True(t, changed) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(out, &decoded)) + assert.Equal(t, "gpt-5.6-sol", decoded["model"]) + assert.Equal(t, "pck-1", decoded["prompt_cache_key"]) + assert.Equal(t, true, decoded["stream"]) + cm, _ := decoded["client_metadata"].(map[string]any) + require.NotNil(t, cm) + assert.Equal(t, ids.sessionID, cm["session_id"]) + assert.Equal(t, ids.turnID, cm["turn_id"]) +} + +func TestApplyCodexFingerprintClientMetadataRaw_Noop(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol"}`) + out, changed, err := applyCodexFingerprintClientMetadataRaw(body, nil) + require.NoError(t, err) + assert.False(t, changed) + assert.Equal(t, body, out) + + out, changed, err = applyCodexFingerprintClientMetadataRaw(nil, &codexFingerprintIDs{mode: codexFingerprintSession, installationID: "x"}) + require.NoError(t, err) + assert.False(t, changed) + assert.Nil(t, out) +} + +// --- context 暂存与出站头应用(透传/非透传共用 seam)--- + +func newFingerprintStageTestContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + return c +} + +func TestStageCodexFingerprintIDs_NilOverwritesPreviousAccount(t *testing.T) { + c := newFingerprintStageTestContext(t) + accountA := newTestOAuthAccount(1001, map[string]any{codexFingerprintModeExtraKey: "session"}) + idsA := resolveCodexFingerprintIDs(accountA, "sess-x", codexFingerprintSession) + require.NotNil(t, idsA) + stageCodexFingerprintIDs(c, idsA) + + // failover 切到 off 模式账号:无条件覆写为 nil,上一账号 IDs 不得残留 + stageCodexFingerprintIDs(c, nil) + + h := http.Header{} + h.Set("session_id", "isolated-session") + accountB := newTestOAuthAccount(1002, map[string]any{"codex_fingerprint_mode": "off"}) + applyStagedCodexFingerprintHeaders(c, accountB, h) + assert.Equal(t, "isolated-session", h.Get("session_id"), "off 账号不得应用上一账号的收敛 ID") + assert.Empty(t, h.Get("x-codex-installation-id")) +} + +func TestApplyStagedCodexFingerprintRejectsDifferentOAuthAccount(t *testing.T) { + c := newFingerprintStageTestContext(t) + accountA := newTestOAuthAccount(1003, map[string]any{codexFingerprintModeExtraKey: "session"}) + idsA := resolveCodexFingerprintIDs(accountA, "sess-a", codexFingerprintSession) + require.NotNil(t, idsA) + stageCodexFingerprintIDs(c, idsA) + + accountB := newTestOAuthAccount(1004, map[string]any{codexFingerprintModeExtraKey: "session"}) + h := make(http.Header) + h.Set("session-id", "account-b-session") + applyStagedCodexFingerprintHeaders(c, accountB, h) + assert.Equal(t, "account-b-session", h.Get("session-id")) + assert.Empty(t, h.Get("x-codex-installation-id")) + + body := map[string]any{"client_metadata": map[string]any{"session_id": "account-b-session"}} + assert.False(t, applyStagedCodexFingerprintClientMetadata(c, accountB, body)) + clientMetadata, ok := body["client_metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "account-b-session", clientMetadata["session_id"]) +} + +func TestApplyStagedCodexFingerprintHeaders_SkipsNonOAuthAccount(t *testing.T) { + c := newFingerprintStageTestContext(t) + oauthIDs := resolveCodexFingerprintIDs(newTestOAuthAccount(1003, map[string]any{codexFingerprintModeExtraKey: "session"}), "sess-y", codexFingerprintSession) + require.NotNil(t, oauthIDs) + stageCodexFingerprintIDs(c, oauthIDs) + + h := http.Header{} + apiKeyAccount := &Account{ID: 1004, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + applyStagedCodexFingerprintHeaders(c, apiKeyAccount, h) + assert.Empty(t, h.Get("x-codex-installation-id"), "stale 收敛 ID 不得应用到非 OAuth 账号") +} + +func TestBuildUpstreamRequestOpenAIPassthrough_AppliesStagedFingerprint(t *testing.T) { + svc := &OpenAIGatewayService{} + // 收敛是显式 opt-in(#5610):显式开启后验证透传路径的出站头收敛。 + account := newTestOAuthAccount(2001, map[string]any{ + "openai_oauth_passthrough": true, + "codex_fingerprint_mode": "session", + }) + + c := newFingerprintStageTestContext(t) + c.Request.Header.Set("session_id", "real-client-session") + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"real-install","session_id":"real-session","sandbox":"seatbelt"}`) + + // 复刻 forwardOpenAIPassthrough 的解析+暂存 seam(默认 session 模式) + ids := resolveCodexFingerprintIDsFromRequest(account, c.Request.Header) + require.NotNil(t, ids) + stageCodexFingerprintIDs(c, ids) + + body := []byte(`{"model":"gpt-5.6-sol","input":[],"stream":true}`) + req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token") + require.NoError(t, err) + + assert.Equal(t, ids.sessionID, req.Header.Get("session_id"), "session 模式下出站 session_id 应为账号级收敛值") + assert.Equal(t, ids.installationID, req.Header.Get("x-codex-installation-id")) + assert.Equal(t, ids.windowID, req.Header.Get("x-codex-window-id")) + assert.Equal(t, ids.threadID, req.Header.Get("x-client-request-id")) + turnMetadata := req.Header.Get("x-codex-turn-metadata") + require.NotEmpty(t, turnMetadata) + assert.Contains(t, turnMetadata, ids.sessionID, "turn-metadata JSON 中的 session_id 应被收敛") + assert.Contains(t, turnMetadata, `"sandbox":"seatbelt"`, "turn-metadata 未指定字段应原样保留") +} + +func TestBuildUpstreamRequestOpenAIPassthrough_OffModeKeepsIsolatedSession(t *testing.T) { + svc := &OpenAIGatewayService{} + account := newTestOAuthAccount(2002, map[string]any{ + "openai_oauth_passthrough": true, + "codex_fingerprint_mode": "off", + }) + + c := newFingerprintStageTestContext(t) + c.Request.Header.Set("session_id", "real-client-session") + c.Request.Header.Set("originator", "codex_cli_rs") + + ids := resolveCodexFingerprintIDsFromRequest(account, c.Request.Header) + require.Nil(t, ids) + stageCodexFingerprintIDs(c, ids) + + body := []byte(`{"model":"gpt-5.6-sol","input":[],"stream":true}`) + req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token") + require.NoError(t, err) + + assert.NotEmpty(t, req.Header.Get("session_id")) + assert.NotEqual(t, resolveConvergedSessionID(testCodexFingerprintSeed), req.Header.Get("session_id"), "off 模式不得收敛 session_id") + assert.Empty(t, req.Header.Get("x-codex-window-id")) +} + +func TestApplyCodexFingerprintClientMetadataRaw_NonObjectBodyUntouched(t *testing.T) { + account := newTestOAuthAccount(4244, map[string]any{codexFingerprintModeExtraKey: "session"}) + ids := resolveCodexFingerprintIDs(account, "client-sess-nonobj", codexFingerprintSession) + require.NotNil(t, ids) + + for _, body := range []string{`[1,2,3]`, `"plain string"`, `not json at all`} { + out, changed, err := applyCodexFingerprintClientMetadataRaw([]byte(body), ids) + require.NoError(t, err) + assert.False(t, changed, "非 JSON 对象 body 不应被改写: %s", body) + assert.Equal(t, []byte(body), out) + } +} diff --git a/backend/internal/service/openai_codex_function_call_id_test.go b/backend/internal/service/openai_codex_function_call_id_test.go index edfcac1d71..18b2df3153 100644 --- a/backend/internal/service/openai_codex_function_call_id_test.go +++ b/backend/internal/service/openai_codex_function_call_id_test.go @@ -66,6 +66,113 @@ func TestFilterCodexInput_KeepsFcID_WhenPreservingReferences(t *testing.T) { require.Equal(t, "fc_validID123", fc["id"], "valid fc* id must be preserved") } +func TestFilterCodexInput_PreservesNativeCustomAndToolSearchIDs(t *testing.T) { + input := []any{ + map[string]any{"type": "custom_tool_call", "id": "ctc_valid", "call_id": "call_custom", "name": "apply_patch"}, + map[string]any{"type": "tool_search_call", "id": "tsc_valid", "call_id": "call_search"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, "ctc_valid", filtered[0].(map[string]any)["id"]) + require.Equal(t, "tsc_valid", filtered[1].(map[string]any)["id"]) +} + +func TestFilterCodexInput_StripsWrongCustomAndToolSearchIDs(t *testing.T) { + input := []any{ + map[string]any{"type": "custom_tool_call", "id": "fc_wrong", "call_id": "call_custom", "name": "apply_patch"}, + map[string]any{"type": "tool_search_call", "id": "fc_wrong", "call_id": "call_search"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.NotContains(t, filtered[0].(map[string]any), "id") + require.NotContains(t, filtered[1].(map[string]any), "id") +} + +func TestFilterCodexInput_MapsItemReferencesToNativeToolCallPair(t *testing.T) { + input := []any{ + map[string]any{"type": "custom_tool_call", "id": "fc_custom", "call_id": "call_custom", "name": "apply_patch"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "fc_custom", "output": "done"}, + map[string]any{"type": "item_reference", "id": "call_custom"}, + map[string]any{"type": "tool_search_call", "id": "fc_search", "call_id": "call_search"}, + map[string]any{"type": "tool_search_output", "call_id": "fc_search", "output": "result"}, + map[string]any{"type": "item_reference", "id": "call_search"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, "ctc_custom", filtered[0].(map[string]any)["call_id"]) + require.Equal(t, "ctc_custom", filtered[1].(map[string]any)["call_id"]) + require.Equal(t, "ctc_custom", filtered[2].(map[string]any)["id"]) + require.Equal(t, "tsc_search", filtered[3].(map[string]any)["call_id"]) + require.Equal(t, "tsc_search", filtered[4].(map[string]any)["call_id"]) + require.Equal(t, "tsc_search", filtered[5].(map[string]any)["id"]) +} + +func TestFilterCodexInput_PreservesAmbiguousItemReference(t *testing.T) { + input := []any{ + map[string]any{"type": "custom_tool_call", "call_id": "call_shared", "name": "apply_patch"}, + map[string]any{"type": "tool_search_call", "call_id": "call_shared"}, + map[string]any{"type": "item_reference", "id": "call_shared"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, "ctc_shared", filtered[0].(map[string]any)["call_id"]) + require.Equal(t, "tsc_shared", filtered[1].(map[string]any)["call_id"]) + require.Equal(t, "call_shared", filtered[2].(map[string]any)["id"]) +} + +func TestFilterCodexInput_PreservesNativeItemIDReferenceIndependentlyFromCallID(t *testing.T) { + input := []any{ + map[string]any{"type": "custom_tool_call", "id": "ctc_item", "call_id": "call_custom", "name": "apply_patch"}, + map[string]any{"type": "item_reference", "id": "ctc_item"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, "ctc_item", filtered[0].(map[string]any)["id"]) + require.Equal(t, "ctc_custom", filtered[0].(map[string]any)["call_id"]) + require.Equal(t, "ctc_item", filtered[1].(map[string]any)["id"]) +} + +func TestFilterCodexInput_ExistingItemIDWinsOverLegacyCallIDMapping(t *testing.T) { + input := []any{ + map[string]any{"type": "custom_tool_call", "call_id": "call_shared", "name": "apply_patch"}, + map[string]any{"type": "function_call_output", "id": "call_shared", "call_id": "call_other", "output": "done"}, + map[string]any{"type": "item_reference", "id": "call_shared"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, "ctc_shared", filtered[0].(map[string]any)["call_id"]) + require.Equal(t, "call_shared", filtered[1].(map[string]any)["id"]) + require.Equal(t, "call_shared", filtered[2].(map[string]any)["id"]) +} + +func TestFilterCodexInput_NormalizesCrossTurnLegacyCallReference(t *testing.T) { + input := []any{ + map[string]any{"type": "item_reference", "id": "call_previous_turn"}, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, "fc_previous_turn", filtered[0].(map[string]any)["id"]) +} + +func TestFilterCodexInput_PreservesNativeRemoteItemReferences(t *testing.T) { + for _, id := range []string{"fc_remote", "ctc_remote", "tsc_remote", "msg_remote", "rs_remote", "vendor_remote"} { + t.Run(id, func(t *testing.T) { + input := []any{map[string]any{"type": "item_reference", "id": id}} + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{PreserveReferences: true}) + + require.Equal(t, id, filtered[0].(map[string]any)["id"]) + }) + } +} + // TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes verifies that // item_* ids are stripped from all call-input types (not output types). func TestFilterCodexInput_StripsItemIDFromAllToolCallInputTypes(t *testing.T) { diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 510ee07cf2..2cb8a45c40 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -76,6 +76,38 @@ func SetCodexCanonicalUserAgentResolver(resolver func() string) { codexCanonicalUAResolver = resolver } +// CodexCanonicalUserAgent 返回当前生效的规范 Codex User-Agent。 +// 取值走与推理相同的解析链:面板 UA 指纹 + 面板/自动同步版本号 + 编译期兜底。 +// 供无账号句柄的出站路径(OAuth 换 Token / 刷新)使用。 +func CodexCanonicalUserAgent() string { + return resolveCodexOutboundIdentity("").userAgent +} + +// CodexCanonicalAuthIdentity 返回凭据面(auth.openai.com:换 Token / 刷新 / whoami) +// 出站请求的身份对:规范 User-Agent 与配套 originator,与推理解析链同源。 +// 凭据面不发 version 头——真实 Codex 客户端在该面只携带 originator 与 User-Agent +// (codex-rs login/default_client.rs 的 default_headers()),version 门槛 +// (issue #3901)只存在于 /backend-api/codex 推理面。 +func CodexCanonicalAuthIdentity() (userAgent, originator string) { + identity := resolveCodexOutboundIdentity("") + return identity.userAgent, identity.originator +} + +// ApplyCodexCanonicalAuthIdentity 为凭据面出站请求写入身份对(不含 version)。 +func ApplyCodexCanonicalAuthIdentity(h http.Header) { + if h == nil { + return + } + userAgent, originator := CodexCanonicalAuthIdentity() + h.Set("user-agent", userAgent) + h.Set("originator", originator) +} + +// CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。 +func CodexCanonicalClientVersion() string { + return resolveCodexOutboundIdentity("").version +} + // codexCanonicalUserAgent 返回出站规范 User-Agent。 func codexCanonicalUserAgent() string { codexCanonicalUAMu.RLock() @@ -210,6 +242,6 @@ func pairCodexIdentityHeaders(h http.Header) { h.Set("user-agent", pairedUA) h.Set("originator", originator) if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 { - h.Set("version", codexCLIVersion) + h.Set("version", resolveCodexOutboundIdentity("").version) } } diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index 9b295241ce..15768e5ea5 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -322,3 +322,27 @@ func TestBuildCodexCLIUserAgent(t *testing.T) { require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version")) require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("")) } + +func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, CodexCanonicalUserAgent()) + require.Equal(t, "0.200.1", CodexCanonicalClientVersion()) + + h := make(http.Header) + ApplyCodexCanonicalAuthIdentity(h) + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent")) + // 凭据面不发 version 头(真实客户端在 auth.openai.com 只带 originator + UA)。 + require.Empty(t, h.Get("version")) +} + +func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(nil) + + require.Equal(t, codexCLIUserAgent, CodexCanonicalUserAgent()) + require.Equal(t, codexCLIVersion, CodexCanonicalClientVersion()) +} diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go new file mode 100644 index 0000000000..c779a05ab9 --- /dev/null +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -0,0 +1,313 @@ +package service + +import "strings" + +func groupCodexModelMetadata( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) (codexModelMetadataOverride, bool) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return codexModelMetadataOverride{}, false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + if codexExplicitModelTargetsConflict(accounts, modelID) { + return codexModelMetadataOverride{ + reasoningConflict: true, + inputModalitiesConflict: true, + }, true + } + return codexModelMetadataOverride{}, false + } + } + if !isConcreteRequestPlatform(platform) { + return codexModelMetadataOverride{}, false + } + + explicitClaims := false + if upstreamModel == modelID { + for _, account := range accounts { + if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) { + explicitClaims = true + break + } + } + } + explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) + publicAlias := upstreamModel != modelID + candidates := make([]UpstreamModelMetadata, 0) + for i := range accounts { + account := &accounts[i] + if account.Platform != platform { + continue + } + var lookupModel string + if explicitClaims { + if !codexExplicitModelMappingClaims(*account, modelID) { + continue + } + lookupModel = account.GetMappedModel(modelID) + } else { + if !account.IsModelSupported(upstreamModel) { + continue + } + lookupModel = account.GetMappedModel(upstreamModel) + } + if strings.TrimSpace(lookupModel) != modelID { + publicAlias = true + } + metadata, ok := account.GetUpstreamModelMetadata(lookupModel) + if !ok { + if explicitTargetsConflict { + return codexModelMetadataOverride{ + reasoningConflict: true, + inputModalitiesConflict: true, + }, true + } + return codexModelMetadataOverride{}, false + } + candidates = append(candidates, metadata) + } + if len(candidates) == 0 { + return codexModelMetadataOverride{}, false + } + metadata := intersectUpstreamModelMetadata(modelID, candidates) + if publicAlias { + metadata.DisplayName = modelID + metadata.Description = configuredCodexCustomDescription + } + return metadata, true +} + +func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool { + targets := make(map[string]struct{}) + for i := range accounts { + account := &accounts[i] + mappedModel, matched := account.ResolveMappedModel(modelID) + mappedModel = strings.TrimSpace(mappedModel) + if !matched || mappedModel == "" { + continue + } + targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{} + } + return len(targets) > 1 +} + +func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, modelID string) bool { + targets := make(map[string]struct{}) + for i := range accounts { + account := &accounts[i] + if account.Platform != platform { + continue + } + mappedModel, matched := account.ResolveMappedModel(modelID) + mappedModel = strings.TrimSpace(mappedModel) + if !matched || mappedModel == "" { + continue + } + targets[mappedModel] = struct{}{} + } + return len(targets) > 1 +} + +func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride { + result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}} + for _, candidate := range candidates { + if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" { + result.DisplayName = strings.TrimSpace(candidate.DisplayName) + } + if result.Description == "" && strings.TrimSpace(candidate.Description) != "" { + result.Description = strings.TrimSpace(candidate.Description) + } + } + + reasoningKnown := true + reasoningValue := false + for i, candidate := range candidates { + if candidate.Reasoning == nil { + reasoningKnown = false + break + } + if i == 0 { + reasoningValue = *candidate.Reasoning + continue + } + if reasoningValue != *candidate.Reasoning { + reasoningKnown = false + result.reasoningConflict = true + break + } + } + if reasoningKnown { + result.Reasoning = &reasoningValue + if reasoningValue { + levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels) + for _, candidate := range candidates[1:] { + levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels)) + } + result.SupportedReasoningLevels = levels + if len(levels) == 0 { + result.reasoningConflict = true + } else { + sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel) + for _, candidate := range candidates[1:] { + if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault { + sharedDefault = "" + break + } + } + if !stringSliceContains(levels, sharedDefault) { + sharedDefault = levels[0] + } + result.DefaultReasoningLevel = sharedDefault + } + } + } + + modalitiesKnown := true + modalities := normalizeCodexInputModalities(candidates[0].InputModalities) + if len(modalities) == 0 { + modalitiesKnown = false + } + for _, candidate := range candidates[1:] { + candidateModalities := normalizeCodexInputModalities(candidate.InputModalities) + if len(candidateModalities) == 0 { + modalitiesKnown = false + break + } + modalities = intersectOrderedStrings(modalities, candidateModalities) + } + if modalitiesKnown && len(modalities) > 0 { + result.InputModalities = modalities + } else if modalitiesKnown { + result.inputModalitiesConflict = true + } + + contextKnown := true + for i, candidate := range candidates { + if candidate.ContextWindow <= 0 { + contextKnown = false + break + } + if i == 0 || candidate.ContextWindow < result.ContextWindow { + result.ContextWindow = candidate.ContextWindow + } + } + if !contextKnown { + result.ContextWindow = 0 + } + return result +} + +func applyUpstreamModelMetadataToCodexDescriptor( + descriptor *configuredCodexModelDescriptor, + metadata codexModelMetadataOverride, +) { + if descriptor == nil { + return + } + if strings.TrimSpace(metadata.DisplayName) != "" { + descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName) + } + if strings.TrimSpace(metadata.Description) != "" { + descriptor.Description = strings.TrimSpace(metadata.Description) + } + if metadata.reasoningConflict { + descriptor.DefaultReasoningLevel = nil + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{} + } else if metadata.Reasoning != nil && !*metadata.Reasoning { + none := "none" + descriptor.DefaultReasoningLevel = &none + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{ + Effort: "none", + Description: configuredCodexReasoningLevelDescription("none"), + }} + } else if metadata.Reasoning != nil && *metadata.Reasoning { + levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels) + if len(levels) == 0 { + descriptor.DefaultReasoningLevel = nil + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{} + } else { + defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel) + if !stringSliceContains(levels, defaultLevel) { + defaultLevel = levels[0] + } + descriptor.DefaultReasoningLevel = &defaultLevel + descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels)) + for _, level := range levels { + descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{ + Effort: level, + Description: configuredCodexReasoningLevelDescription(level), + }) + } + } + } + if metadata.inputModalitiesConflict { + descriptor.InputModalities = []string{"text"} + } else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { + descriptor.InputModalities = modalities + } + if metadata.ContextWindow > 0 { + descriptor.ContextWindow = metadata.ContextWindow + descriptor.MaxContextWindow = metadata.ContextWindow + } +} + +func configuredCodexReasoningLevelDescription(level string) string { + switch level { + case "none": + return "Use the model's default behavior without configurable reasoning" + case "minimal": + return "Minimal reasoning for the fastest responses" + case "low": + return "Fast responses with lighter reasoning" + case "medium": + return "Balanced reasoning for most coding tasks" + case "high": + return "Greater reasoning depth for coding and agent tasks" + case "xhigh": + return "Extra-high reasoning depth for difficult tasks" + case "max": + return "Maximum reasoning depth for complex tasks" + default: + return "Reasoning effort supported by the upstream model" + } +} + +func intersectOrderedStrings(left, right []string) []string { + rightSet := make(map[string]struct{}, len(right)) + for _, value := range right { + rightSet[value] = struct{}{} + } + intersection := make([]string, 0, len(left)) + for _, value := range left { + if _, ok := rightSet[value]; ok { + intersection = append(intersection, value) + } + } + return intersection +} + +func stringSliceContains(values []string, target string) bool { + if target == "" { + return false + } + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go new file mode 100644 index 0000000000..5161617d7b --- /dev/null +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// Scenario: mixed groups prefer capability metadata synced for the routed account. +func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 735 + account := Account{ + ID: 25, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/v1", + "model_mapping": map[string]any{"x-preview-f-free": "x-preview-f-free"}, + }, + Extra: map[string]any{ + UpstreamModelMetadataExtraKey: map[string]any{ + "source": "models.dev", + "models": map[string]any{ + "x-preview-f-free": map[string]any{ + "id": "x-preview-f-free", + "display_name": "Ox Alpha Free (Unlimited)", + "description": "Stealth reasoning model", + "reasoning": true, + "supported_reasoning_levels": []any{"low", "high", "max"}, + "input_modalities": []any{"text", "image"}, + "context_window": float64(1_000_000), + "max_output_tokens": float64(131_072), + }, + }, + }, + }, + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {account}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"x-preview-f-free"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "Ox Alpha Free (Unlimited)", models[0]["display_name"]) + require.Equal(t, "low", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"low", "high", "max"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + +// Scenario: an explicitly non-reasoning model remains directly selectable in Codex. +func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 737 + reasoning := false + account := Account{ + ID: 28, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"company-coding-model": "company-coding-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "company-coding-model": { + ID: "company-coding-model", Reasoning: &reasoning, + InputModalities: []string{"text"}, ContextWindow: 64_000, + }, + }}) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {account}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"company-coding-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "none", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0])) +} + +// Scenario: multiple schedulable accounts advertise only their shared capabilities. +func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 736 + reasoning := true + newAccount := func(id int64, levels, modalities []string, contextWindow int64) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"shared-model": "shared-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "shared-model": { + ID: "shared-model", Reasoning: &reasoning, + SupportedReasoningLevels: levels, + InputModalities: modalities, + ContextWindow: contextWindow, + }, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(26, []string{"low", "high"}, []string{"text", "image"}, 256_000), + newAccount(27, []string{"high", "max"}, []string{"text"}, 128_000), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []string{"high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "high", models[0]["default_reasoning_level"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 128_000, models[0]["context_window"]) +} + +// Scenario: the same public alias may target different models on one platform when complete snapshots can be intersected. +func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithoutLeakingAlias(t *testing.T) { + t.Parallel() + + const groupID int64 = 739 + reasoning := true + newAccount := func(id int64, target, displayName, description string, levels, modalities []string, contextWindow int64) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"my-coder": target}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + target: { + ID: target, DisplayName: displayName, Description: description, Reasoning: &reasoning, + SupportedReasoningLevels: levels, + InputModalities: modalities, + ContextWindow: contextWindow, + }, + }}) + return account + } + openAIAccount := newAccount( + 31, + "gpt-5.6-sol", + "GPT-5.6 Sol", + "OpenAI upstream model", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 272_000, + ) + arkAccount := newAccount( + 32, + "glm-5.3", + "GLM 5.3", + "Ark upstream model", + []string{"low", "medium", "high"}, + []string{"text"}, + 1_000_000, + ) + + for _, accounts := range [][]Account{{openAIAccount, arkAccount}, {arkAccount, openAIAccount}} { + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "my-coder", models[0]["slug"]) + require.Equal(t, "my-coder", models[0]["display_name"]) + require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 272_000, models[0]["context_window"]) + } +} + +// Scenario: temporarily unschedulable mapped accounts still participate in capability intersection. +func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) { + t.Parallel() + + const groupID int64 = 741 + schedulable := newCodexCatalogMappedAccount( + 41, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + ) + unschedulable := newCodexCatalogMappedAccount( + 42, + "glm-5.3", + "GLM 5.3", + []string{"low", "medium", "high"}, + []string{"text"}, + 272_000, + false, + map[string]any{"exclusive-model": "exclusive-upstream"}, + ) + svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {schedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "my-coder", models[0]["slug"]) + require.Equal(t, "my-coder", models[0]["display_name"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 272_000, models[0]["context_window"]) +} + +// Scenario: deleting an account can widen the advertised contract. +func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) { + t.Parallel() + + const groupID int64 = 742 + remaining := newCodexCatalogMappedAccount( + 41, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + ) + svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {remaining}}, + catalog: map[int64][]Account{groupID: {remaining}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + +func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) { + t.Parallel() + + const groupID int64 = 743 + repo := &countingCodexModelsAccountRepo{ + accounts: []Account{newCodexCatalogMappedAccount( + 41, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + )}, + listByGroupErr: errors.New("group listing unavailable"), + } + svc := &GatewayService{accountRepo: repo} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + +// Scenario: a Composite alias claimed across platforms remains ambiguous and fails closed. +func TestBuildCodexModelsManifestForGroupKeepsCrossPlatformAliasAmbiguityClosed(t *testing.T) { + t.Parallel() + + const groupID int64 = 740 + reasoning := true + newAccount := func(id int64, platform, target string) Account { + account := Account{ + ID: id, Platform: platform, Type: AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{"shared-alias": target}}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + target: { + ID: target, DisplayName: target, Reasoning: &reasoning, + SupportedReasoningLevels: []string{"low", "high"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 128_000, + }, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(33, PlatformOpenAI, "gpt-5.6-sol"), + newAccount(34, PlatformGrok, "grok-4.6"), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-alias"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "shared-alias", models[0]["display_name"]) + require.Empty(t, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasoningConflicts(t *testing.T) { + t.Parallel() + + const groupID int64 = 738 + reasoning := true + noReasoning := false + newAccount := func(id int64, metadata UpstreamModelMetadata) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"shared-model": "shared-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "shared-model": metadata, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(29, UpstreamModelMetadata{ + ID: "shared-model", Reasoning: &reasoning, + SupportedReasoningLevels: []string{"low", "high"}, + InputModalities: []string{"text"}, ContextWindow: 128_000, + }), + newAccount(30, UpstreamModelMetadata{ + ID: "shared-model", Reasoning: &noReasoning, + InputModalities: []string{"text"}, ContextWindow: 128_000, + }), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + _, hasDefault := models[0]["default_reasoning_level"] + require.False(t, hasDefault) + require.Empty(t, models[0]["supported_reasoning_levels"]) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index c36c46eb13..95be9ebbaf 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -16,9 +16,11 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) @@ -28,20 +30,1196 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestBodyLimit int64 = 8 << 20 - codexModelsManifestCacheBodyLimit = 1 << 20 - codexModelsManifestCacheMaxEntries = 64 - codexModelsManifestCacheTTL = 30 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute - codexModelsManifestRequestTimeout = 15 * time.Second + codexModelsManifestCacheBodyLimit = 1 << 20 + codexModelsManifestCacheMaxEntries = 64 + codexModelsManifestCacheTTL = 30 * time.Second + codexModelsManifestCacheStaleTTL = 5 * time.Minute + codexModelsManifestRequestTimeout = 15 * time.Second + codexAutoModelPrefix = "codex-auto-" ) +// FilterCodexModelIDsForGroup removes dedicated media-generation models, +// wildcard mapping keys, and Codex automatic modes from a client catalog. +// Automatic modes are retained only when the group's enabled custom model list +// explicitly selects the exact slug; account model mappings describe routing +// and are not feature opt-ins. Wildcard keys such as "foo-*" are routing +// patterns, not concrete Codex models. +func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string { + explicitlyEnabled := make(map[string]struct{}) + if group != nil && group.CustomModelsListEnabled() { + for _, modelID := range group.ModelsListConfig.Models { + modelID = strings.TrimSpace(modelID) + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + explicitlyEnabled[modelID] = struct{}{} + } + } + } + + filtered := make([]string, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if isCodexDedicatedMediaModel(modelID) { + continue + } + if strings.Contains(modelID, "*") { + continue + } + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + if _, ok := explicitlyEnabled[modelID]; !ok { + continue + } + } + filtered = append(filtered, modelID) + } + return filtered +} + +func isCodexDedicatedMediaModel(modelID string) bool { + canonical := codexProviderQualifiedModelID(modelID) + return IsGPTImageGenerationModel(canonical) || + isImageGenerationModel(canonical) || + xai.IsGrokImagineModel(modelID) +} + +func codexProviderQualifiedModelID(modelID string) string { + modelID = strings.TrimSpace(modelID) + if slash := strings.LastIndexByte(modelID, '/'); slash >= 0 { + modelID = strings.TrimSpace(modelID[slash+1:]) + } + return strings.TrimPrefix(modelID, "models/") +} + // CodexModelsManifest carries the client representation plus caching metadata. type CodexModelsManifest struct { - Body []byte - ETag string - upstreamETag string - NotModified bool + Body []byte + ETag string + upstreamETag string + upstreamSourceBody []byte + convertedFromOpenAIModelList bool + NotModified bool +} + +// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively +// from the public model names configured on accounts in an OpenAI group. The +// boolean result distinguishes "no explicit configuration" from a configured +// catalog that becomes empty after group-level filtering. +func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( + ctx context.Context, + group *Group, + ifNoneMatch string, +) (*CodexModelsManifest, bool, error) { + if s == nil || s.accountRepo == nil || group == nil || group.Platform != PlatformOpenAI { + return nil, false, nil + } + + visible, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID) + if err != nil { + return nil, false, fmt.Errorf("load group configured Codex models: %w", err) + } + configuredModels := openAIConfiguredCodexModelIDsForGroup(visible, group) + if len(configuredModels) == 0 { + return nil, false, nil + } + + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, + configuredModels, + catalog, + nil, + true, + ) + if err != nil { + return nil, false, fmt.Errorf("initialize group configured Codex models: %w", err) + } + body, _, err = mergeConfiguredCodexModelsManifest( + body, + nil, + group.ModelsListConfig.Models, + group.CustomModelsListEnabled(), + ) + if err != nil { + return nil, false, fmt.Errorf("build group configured Codex models: %w", err) + } + manifest := &CodexModelsManifest{ + Body: body, + ETag: codexModelsManifestBodyETag(body), + } + if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { + manifest.Body = nil + manifest.NotModified = true + } + return manifest, true, nil +} + +// MergeGroupConfiguredCodexModels adds account model aliases that are visible +// to the authenticated OpenAI group without discarding metadata from upstream +// Codex model entries. A group's custom models list also filters the picker, +// matching the standard /v1/models display policy. +func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( + ctx context.Context, + group *Group, + manifest *CodexModelsManifest, + ifNoneMatch string, +) error { + if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified { + return nil + } + if group.Platform != PlatformOpenAI || len(manifest.Body) == 0 { + return nil + } + + configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group) + if err != nil { + return fmt.Errorf("load group configured Codex models: %w", err) + } + body, changed, err := mergeConfiguredCodexModelsManifest( + manifest.Body, + configuredModels, + group.ModelsListConfig.Models, + group.CustomModelsListEnabled(), + ) + if err != nil { + return fmt.Errorf("merge group configured Codex models: %w", err) + } + if changed { + manifest.Body = body + manifest.ETag = codexModelsManifestBodyETag(body) + } + if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { + manifest.Body = nil + manifest.NotModified = true + } + return nil +} + +func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, group *Group) ([]string, error) { + if group == nil { + return nil, nil + } + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID) + if err != nil { + return nil, err + } + return openAIConfiguredCodexModelIDsForGroup(accounts, group), nil +} + +// loadCodexGroupCatalogAccounts separates picker membership from capability +// intersection. visible accounts are currently schedulable and decide which +// public aliases appear. catalog accounts are all non-deleted active group +// members; their snapshots keep advertised capabilities from widening when a +// mapped account is only temporarily unschedulable. If ListByGroup fails, the +// catalog falls back to the schedulable set so a listing error does not fail +// the client request. +func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, groupID int64) (visible []Account, catalog []Account, err error) { + if repo == nil { + return nil, nil, nil + } + visible, err = repo.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return nil, nil, err + } + catalog = visible + groupAccounts, listErr := repo.ListByGroup(ctx, groupID) + if listErr != nil { + return visible, catalog, nil + } + return visible, groupAccounts, nil +} + +func openAIConfiguredCodexModelIDs(accounts []Account) []string { + seen := make(map[string]struct{}) + models := make([]string, 0) + for i := range accounts { + account := &accounts[i] + if account.Platform != PlatformOpenAI { + continue + } + for modelID := range account.GetModelMapping() { + modelID = strings.TrimSpace(modelID) + if modelID == "" || strings.Contains(modelID, "*") { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + models = append(models, modelID) + } + } + sort.Strings(models) + return models +} + +func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string { + models := openAIConfiguredCodexModelIDs(accounts) + if group == nil || !group.CustomModelsListEnabled() { + return models + } + + seen := make(map[string]struct{}, len(models)+len(group.ModelsListConfig.Models)) + for _, modelID := range models { + seen[modelID] = struct{}{} + } + for _, selectedModel := range group.ModelsListConfig.Models { + selectedModel = strings.TrimSpace(selectedModel) + if selectedModel == "" || strings.Contains(selectedModel, "*") { + continue + } + for i := range accounts { + account := &accounts[i] + if account.Platform != PlatformOpenAI { + continue + } + mappedModel, matched := account.ResolveMappedModel(selectedModel) + if !matched || strings.TrimSpace(mappedModel) == "" { + continue + } + if _, exists := seen[selectedModel]; !exists { + seen[selectedModel] = struct{}{} + models = append(models, selectedModel) + } + break + } + } + sort.Strings(models) + return models +} + +const ( + configuredCodexModelPriority = 50 + configuredCodexCustomDescription = "Custom model routed through Sub2API." + configuredCodexFallbackContext = 272_000 + configuredCodexDeepSeekV4Context = 1_000_000 + configuredCodexGrokContext = 500_000 + configuredCodexGrokBuildContext = 256_000 + configuredCodexGPT56MaxContext = 872_000 + configuredCodexToolOutputMaxTokens = 10_000 +) + +type configuredCodexReasoningLevel struct { + Effort string `json:"effort"` + Description string `json:"description"` +} + +type configuredCodexTruncationPolicy struct { + Mode string `json:"mode"` + Limit int64 `json:"limit"` +} + +type configuredCodexModelMessages struct { + InstructionsTemplate string `json:"instructions_template"` + InstructionsVariables any `json:"instructions_variables"` + Approvals any `json:"approvals"` + CollaborationModes any `json:"collaboration_modes"` + AutoReview any `json:"auto_review"` + Permissions any `json:"permissions"` + MultiAgent any `json:"multi_agent"` + TokenBudget any `json:"token_budget"` + GuardianV2 any `json:"guardian_v2"` +} + +// configuredCodexModelDescriptor is the minimum complete ModelInfo contract +// understood by current Codex clients. Several nullable fields are intentionally +// emitted: unlike ordinary OpenAI /v1/models entries, the Codex manifest parser +// requires them to be present. +type configuredCodexModelDescriptor struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"` + ShellType string `json:"shell_type"` + Visibility string `json:"visibility"` + SupportedInAPI bool `json:"supported_in_api"` + Priority int `json:"priority"` + AdditionalSpeedTiers []string `json:"additional_speed_tiers"` + ServiceTiers []any `json:"service_tiers"` + DefaultServiceTier any `json:"default_service_tier"` + AvailabilityNUX any `json:"availability_nux"` + Upgrade any `json:"upgrade"` + ModelMessages configuredCodexModelMessages `json:"model_messages"` + IncludeSkillsUsageInstructions bool `json:"include_skills_usage_instructions"` + IncludePluginUsageInstructions bool `json:"include_plugin_usage_instructions"` + IncludeAppsUsageInstructions bool `json:"include_apps_usage_instructions"` + SupportsReasoningSummaryParameter bool `json:"supports_reasoning_summary_parameter"` + DefaultReasoningSummary string `json:"default_reasoning_summary"` + SupportVerbosity bool `json:"support_verbosity"` + DefaultVerbosity *string `json:"default_verbosity"` + ApplyPatchToolType *string `json:"apply_patch_tool_type"` + WebSearchToolType string `json:"web_search_tool_type"` + TruncationPolicy configuredCodexTruncationPolicy `json:"truncation_policy"` + SupportsImageDetailOriginal bool `json:"supports_image_detail_original"` + SupportsParallelToolCalls bool `json:"supports_parallel_tool_calls"` + ContextWindow int64 `json:"context_window"` + MaxContextWindow int64 `json:"max_context_window"` + AutoCompactTokenLimit any `json:"auto_compact_token_limit"` + CompHash any `json:"comp_hash"` + EffectiveContextWindowPercent int64 `json:"effective_context_window_percent"` + ExperimentalSupportedTools []string `json:"experimental_supported_tools"` + InputModalities []string `json:"input_modalities"` + SupportsSearchTool bool `json:"supports_search_tool"` + UseResponsesLite bool `json:"use_responses_lite"` + NodeREPLAutoReviewRequired bool `json:"node_repl_auto_review_required"` + NodeREPLDisabled bool `json:"node_repl_disabled"` + AutoReviewModelOverride any `json:"auto_review_model_override"` + ModelSpecialty any `json:"model_specialty"` + ToolMode any `json:"tool_mode"` + MultiAgentVersion any `json:"multi_agent_version"` +} + +type codexModelMetadataOverride struct { + UpstreamModelMetadata + reasoningConflict bool + inputModalitiesConflict bool +} + +func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescriptor { + modelID = strings.TrimSpace(modelID) + noReasoningLevel := "none" + descriptor := configuredCodexModelDescriptor{ + Slug: modelID, + DisplayName: modelID, + Description: configuredCodexCustomDescription, + DefaultReasoningLevel: &noReasoningLevel, + SupportedReasoningLevels: []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, + ShellType: "unified_exec", + Visibility: "list", + SupportedInAPI: true, + Priority: configuredCodexModelPriority, + AdditionalSpeedTiers: []string{}, + ServiceTiers: []any{}, + ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)}, + SupportsReasoningSummaryParameter: true, + DefaultReasoningSummary: "auto", + WebSearchToolType: "text", + TruncationPolicy: configuredCodexTruncationPolicy{Mode: "bytes", Limit: configuredCodexToolOutputMaxTokens}, + ContextWindow: configuredCodexFallbackContext, + MaxContextWindow: configuredCodexFallbackContext, + EffectiveContextWindowPercent: 95, + ExperimentalSupportedTools: []string{}, + InputModalities: []string{"text"}, + } + + if isDeepSeekCodexModel(modelID) { + defaultReasoningLevel := "high" + descriptor.DisplayName = deepSeekCodexDisplayName(modelID) + descriptor.Description = "DeepSeek coding and reasoning model routed through Sub2API." + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, + } + descriptor.SupportsParallelToolCalls = true + descriptor.ContextWindow = configuredCodexDeepSeekV4Context + descriptor.MaxContextWindow = configuredCodexDeepSeekV4Context + } + + if isGrokCodexModel(modelID) { + descriptor.DisplayName = grokCodexDisplayName(modelID) + descriptor.Description = "Grok coding and reasoning model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + descriptor.ContextWindow = grokCodexContextWindow(modelID) + descriptor.MaxContextWindow = descriptor.ContextWindow + if grokCodexSupportsReasoningEffort(modelID) { + defaultReasoningLevel := "high" + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = configuredCodexGrokReasoningLevels(modelID) + } + } + + if isClaudeCodexModel(modelID) { + descriptor.DisplayName = claudeCodexDisplayName(modelID) + descriptor.Description = "Claude coding and reasoning model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + if levels := configuredCodexClaudeReasoningLevels(modelID); len(levels) > 0 { + defaultReasoningLevel := claudeCodexDefaultReasoningLevel(levels) + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = levels + } + } + + if isOpenAICodexGPTModel(modelID) { + descriptor.DisplayName = openaiCodexDisplayName(modelID) + descriptor.Description = "OpenAI GPT coding model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + if isOpenAICodexReasoningGPTModel(modelID) { + defaultReasoningLevel := "medium" + if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" { + defaultReasoningLevel = "low" + } + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) + descriptor.DefaultReasoningSummary = "none" + descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} + if isOpenAIGPT56Model(modelID) { + descriptor.MaxContextWindow = configuredCodexGPT56MaxContext + } + } + if SupportsVerbosity(modelID) { + defaultVerbosity := "low" + descriptor.SupportVerbosity = true + descriptor.DefaultVerbosity = &defaultVerbosity + } + } + + return descriptor +} + +func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { + levels := []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "medium", Description: "Balanced reasoning for most coding tasks"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + } + if grokSupportsXHighReasoningEffort(modelID) { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "xhigh", + Description: "Extra-high reasoning depth for difficult tasks", + }) + } + return levels +} + +func configuredCodexClaudeReasoningLevels(modelID string) []configuredCodexReasoningLevel { + descriptions := map[string]string{ + "low": "Fast responses with lighter reasoning", + "medium": "Balanced reasoning for most coding tasks", + "high": "Greater reasoning depth for coding and agent tasks", + "xhigh": "Extra-high reasoning depth for difficult tasks", + "max": "Maximum reasoning depth for complex tasks", + } + levels := claude.EffortLevelsForModel(modelID) + out := make([]configuredCodexReasoningLevel, 0, len(levels)) + for _, effort := range levels { + out = append(out, configuredCodexReasoningLevel{ + Effort: effort, + Description: descriptions[effort], + }) + } + return out +} + +func claudeCodexDefaultReasoningLevel(levels []configuredCodexReasoningLevel) string { + for _, preferred := range []string{"medium", "high", "low"} { + for _, level := range levels { + if level.Effort == preferred { + return preferred + } + } + } + if len(levels) == 0 { + return "" + } + return levels[0].Effort +} + +func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasoningLevel { + levels := []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "medium", Description: "Balanced reasoning for most coding tasks"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, + } + normalized := getNormalizedCodexModel(modelID) + if isOpenAIGPT56Model(modelID) { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "max", + Description: "Maximum reasoning depth for complex tasks", + }) + } + if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "ultra", + Description: "Maximum reasoning with automatic task delegation", + }) + } + return levels +} + +func isOpenAICodexGPTModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + if normalized == "" || strings.HasPrefix(normalized, "gpt-image") { + return false + } + return strings.HasPrefix(normalized, "gpt-") +} + +func isOpenAICodexReasoningGPTModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return strings.HasPrefix(normalized, "gpt-5") +} + +func isOpenAICodexImageInputModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return strings.HasPrefix(normalized, "gpt-5") || + strings.HasPrefix(normalized, "gpt-4o") || + strings.HasPrefix(normalized, "gpt-4.1") || + strings.HasPrefix(normalized, "gpt-4.5") || + strings.HasPrefix(normalized, "gpt-4-turbo") || + strings.HasPrefix(normalized, "gpt-4-vision") +} + +func isOfficialOpenAICodexCatalogModel(modelID string) bool { + normalized := strings.ToLower(codexProviderQualifiedModelID(modelID)) + if normalized == "" || isCodexDedicatedMediaModel(normalized) { + return false + } + if strings.HasPrefix(normalized, "codex-") { + return true + } + if strings.HasPrefix(normalized, "o1") || strings.HasPrefix(normalized, "o3") || strings.HasPrefix(normalized, "o4") { + return true + } + if !strings.HasPrefix(normalized, "gpt-") { + return false + } + for _, incompatibleFamily := range []string{"audio", "realtime", "transcribe", "tts"} { + if strings.Contains(normalized, incompatibleFamily) { + return false + } + } + return true +} + +func openaiCodexDisplayName(modelID string) string { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + if normalized == "" { + return modelID + } + for _, model := range openai.DefaultModels { + if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + return modelID +} + +func deepSeekCodexDisplayName(modelID string) string { + switch strings.ToLower(strings.TrimSpace(modelID)) { + case "deepseek-v4-pro", "deepseek-4-pro": + return "DeepSeek V4 Pro" + case "deepseek-v4-flash", "deepseek-4-flash": + return "DeepSeek V4 Flash" + default: + return modelID + } +} + +func isDeepSeekCodexModel(modelID string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "deepseek-") +} + +func isGrokCodexModel(modelID string) bool { + return xai.IsGrokModelID(modelID) +} + +func grokCodexSupportsReasoningEffort(modelID string) bool { + if grokSupportsReasoningEffort(modelID) { + return true + } + canonical := xai.ResolveGrokTextResponsesModelID(modelID) + if canonical == "" || strings.EqualFold(canonical, modelID) { + return false + } + return grokSupportsReasoningEffort(canonical) +} + +func grokCodexDisplayName(modelID string) string { + normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) + if normalized == "" { + return modelID + } + if name := grokDefaultDisplayName(normalized); name != "" { + return name + } + canonical := strings.ToLower(xai.ResolveGrokTextResponsesModelID(normalized)) + if canonical != "" && canonical != normalized { + if name := grokDefaultDisplayName(canonical); name != "" { + return name + } + } + return modelID +} + +func grokDefaultDisplayName(modelID string) string { + for _, model := range xai.DefaultModels() { + if model.ID == modelID { + return strings.TrimSpace(model.DisplayName) + } + } + return "" +} + +func grokCodexContextWindow(modelID string) int64 { + normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) + if strings.HasPrefix(normalized, "grok-build") { + return configuredCodexGrokBuildContext + } + return configuredCodexGrokContext +} + +func isClaudeCodexModel(modelID string) bool { + platform, detected := DetectModelPlatform(modelID) + return detected && platform == PlatformAnthropic +} + +func claudeCodexDisplayName(modelID string) string { + normalized := strings.ToLower(codexProviderQualifiedModelID(modelID)) + normalized = strings.TrimPrefix(normalized, "anthropic.") + if normalized == "" { + return modelID + } + for _, model := range claude.DefaultModels { + if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + if canonical, ok := claude.ModelIDOverrides[normalized]; ok { + for _, model := range claude.DefaultModels { + if model.ID == canonical && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + } + return modelID +} + +// BuildCodexModelsManifest builds a standalone Codex model catalog for models +// routed through a custom provider. The response is also suitable for saving +// as model_catalog_json in clients that do not refresh custom-provider catalogs. +func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { + return buildCodexModelsManifest(modelIDs, nil, nil, nil) +} + +// BuildCodexModelsManifestForGroup derives input capabilities from the +// concrete Responses route and group accounts behind a group. Unknown or mixed +// capabilities fail closed to the text-only descriptor used by the standalone +// builder. Caller-supplied model IDs still decide which slugs appear; advertised +// capabilities intersect all active group members that map the alias, including +// accounts that are not currently schedulable. +func (s *GatewayService) BuildCodexModelsManifestForGroup( + ctx context.Context, + group *Group, + platformOverride string, + modelIDs []string, +) ([]byte, error) { + if s == nil || s.accountRepo == nil || group == nil { + return BuildCodexModelsManifest(modelIDs) + } + effectivePlatform := strings.TrimSpace(platformOverride) + if effectivePlatform == "" { + effectivePlatform = group.Platform + } + if effectivePlatform != PlatformComposite && !isConcreteRequestPlatform(effectivePlatform) { + return BuildCodexModelsManifest(modelIDs) + } + + _, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID) + if err != nil { + return BuildCodexModelsManifest(modelIDs) + } + var compositeRoutes []CompositeModelRoute + compositeRoutesAvailable := true + if effectivePlatform == PlatformComposite && s.compositeResolver != nil && s.compositeResolver.repo != nil { + compositeRoutes, err = s.compositeResolver.repo.ListByGroup(ctx, group.ID, false) + if err != nil { + compositeRoutesAvailable = false + } + } + return buildCodexModelsManifestForAccounts( + effectivePlatform, + modelIDs, + catalog, + compositeRoutes, + compositeRoutesAvailable, + ) +} + +func buildCodexModelsManifestForAccounts( + effectivePlatform string, + modelIDs []string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) ([]byte, error) { + imageInputModels := make(map[string]bool, len(modelIDs)) + metadataModels := codexCatalogMetadataModels( + effectivePlatform, + modelIDs, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + modelMetadata := make(map[string]codexModelMetadataOverride, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if groupCodexModelSupportsImageInput( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) { + imageInputModels[modelID] = true + } + if metadata, ok := groupCodexModelMetadata( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ); ok { + modelMetadata[modelID] = metadata + } + } + return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels, modelMetadata) +} + +func buildCodexModelsManifest( + modelIDs []string, + imageInputModels map[string]bool, + metadataModels map[string]string, + modelMetadata map[string]codexModelMetadataOverride, +) ([]byte, error) { + seen := make(map[string]struct{}, len(modelIDs)) + models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + metadataModelID := strings.TrimSpace(metadataModels[modelID]) + if metadataModelID == "" { + metadataModelID = modelID + } + if isCodexDedicatedMediaModel(modelID) || isCodexDedicatedMediaModel(metadataModelID) { + continue + } + seen[modelID] = struct{}{} + descriptor := newConfiguredCodexModelDescriptor(metadataModelID) + descriptor.Slug = modelID + if imageInputModels[modelID] { + descriptor.InputModalities = []string{"text", "image"} + } + if metadata, ok := modelMetadata[modelID]; ok { + applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) + } + if metadataModelID != modelID { + descriptor.DisplayName = modelID + descriptor.Description = configuredCodexCustomDescription + } + models = append(models, descriptor) + } + return json.Marshal(struct { + Models []configuredCodexModelDescriptor `json:"models"` + }{Models: models}) +} + +func codexCatalogMetadataModels( + platform string, + modelIDs []string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) map[string]string { + metadataModels := make(map[string]string, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + metadataModelID := resolveCodexCatalogMetadataModel( + platform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if metadataModelID != "" && metadataModelID != modelID { + metadataModels[modelID] = metadataModelID + } + } + return metadataModels +} + +func resolveCodexCatalogMetadataModel( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) string { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return "" + } + if platform == PlatformComposite { + if !compositeRoutesAvailable { + return modelID + } + if route, matched := matchCompositeRoute(compositeRoutes, modelID, CompositeRouteEndpointResponses); matched { + if upstreamModel := strings.TrimSpace(route.UpstreamModel); upstreamModel != "" { + return upstreamModel + } + return modelID + } + if codexCompositeRouteMatchesModel(compositeRoutes, modelID) { + return modelID + } + + claimedPlatforms := make(map[string]struct{}) + for _, account := range accounts { + accountPlatform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(accountPlatform) || !codexExplicitModelMappingClaims(account, modelID) { + continue + } + claimedPlatforms[accountPlatform] = struct{}{} + } + if len(claimedPlatforms) > 1 { + return modelID + } + for accountPlatform := range claimedPlatforms { + return uniqueCodexMappedModel(accounts, accountPlatform, modelID) + } + + detectedPlatform, detected := DetectModelPlatform(modelID) + if !detected { + return modelID + } + platform = detectedPlatform + } + return uniqueCodexMappedModel(accounts, platform, modelID) +} + +func uniqueCodexMappedModel(accounts []Account, platform string, modelID string) string { + targets := make(map[string]struct{}) + for i := range accounts { + account := &accounts[i] + if account.Platform != platform { + continue + } + mappedModel, matched := account.ResolveMappedModel(modelID) + mappedModel = strings.TrimSpace(mappedModel) + if !matched || mappedModel == "" { + continue + } + targets[mappedModel] = struct{}{} + } + if len(targets) != 1 { + return modelID + } + for target := range targets { + return target + } + return modelID +} + +func groupCodexModelSupportsImageInput( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) bool { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + return false + } + } + if platform != PlatformOpenAI && platform != PlatformGrok { + return false + } + + candidates := 0 + for i := range accounts { + account := &accounts[i] + if account.Platform != platform || !account.IsModelSupported(upstreamModel) { + continue + } + candidates++ + if !accountCodexModelSupportsImageInput(account, account.GetMappedModel(upstreamModel)) { + return false + } + } + return candidates > 0 +} + +func resolveCodexCompositeModelTarget( + modelID string, + accounts []Account, + routes []CompositeModelRoute, + routesAvailable bool, +) (string, string, bool) { + if !routesAvailable { + return "", "", false + } + if route, matched := matchCompositeRoute(routes, modelID, CompositeRouteEndpointResponses); matched { + upstreamModel := strings.TrimSpace(route.UpstreamModel) + if upstreamModel == "" { + upstreamModel = modelID + } + return route.TargetPlatform, upstreamModel, true + } + if codexCompositeRouteMatchesModel(routes, modelID) { + return "", "", false + } + + claimedPlatforms := make(map[string]struct{}) + for _, account := range accounts { + platform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(platform) || !codexExplicitModelMappingClaims(account, modelID) { + continue + } + claimedPlatforms[platform] = struct{}{} + } + if len(claimedPlatforms) > 1 { + return "", "", false + } + for platform := range claimedPlatforms { + return platform, modelID, true + } + + platform, detected := DetectModelPlatform(modelID) + if !detected { + return "", "", false + } + return platform, modelID, true +} + +func codexCompositeRouteMatchesModel(routes []CompositeModelRoute, modelID string) bool { + for _, route := range routes { + publicModel := strings.TrimSpace(route.PublicModel) + if publicModel == "" { + continue + } + switch normalizeCompositeRouteMatchType(route.MatchType) { + case CompositeRouteMatchPrefix: + if strings.HasPrefix(modelID, publicModel) { + return true + } + default: + if modelID == publicModel { + return true + } + } + } + return false +} + +func codexExplicitModelMappingClaims(account Account, modelID string) bool { + if account.Credentials == nil || strings.TrimSpace(modelID) == "" { + return false + } + mapped := strings.TrimSpace(account.GetModelMapping()[modelID]) + return mapped != "" +} + +func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) bool { + if account == nil { + return false + } + switch account.Platform { + case PlatformOpenAI: + if !isOpenAICodexImageInputModel(upstreamModel) { + return false + } + if account.IsOpenAIOAuth() { + return true + } + if !account.IsOpenAIApiKey() { + return false + } + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) + case PlatformGrok: + if !isOfficialGrokCodexBaseURL(account.GetGrokBaseURL()) { + return false + } + canonical := xai.ResolveGrokTextResponsesModelID(upstreamModel) + return isGrokCodexImageInputModel(canonical) + default: + return false + } +} + +func isGrokCodexImageInputModel(model string) bool { + switch strings.ToLower(strings.TrimSpace(model)) { + case "grok-4.3", + "grok-4.5", + "grok-4.6", + "grok-build-0.1", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-4.20-multi-agent-0309": + return true + default: + return false + } +} + +func isOfficialGrokCodexBaseURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" { + return false + } + return xai.IsOfficialBaseURLHost(strings.TrimSuffix(parsed.Hostname(), ".")) +} + +// BuildDeepSeekCodexModelsManifest preserves the historical entry point for +// callers that still use the provider-specific function name. +func BuildDeepSeekCodexModelsManifest(modelIDs []string) ([]byte, error) { + return BuildCodexModelsManifest(modelIDs) +} + +func mergeConfiguredCodexModelsManifest( + body []byte, + configuredModels []string, + selectedModels []string, + filterBySelection bool, +) ([]byte, bool, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, false, err + } + var upstreamModels []json.RawMessage + if err := json.Unmarshal(envelope["models"], &upstreamModels); err != nil { + return nil, false, err + } + + selected := make(map[string]struct{}, len(selectedModels)) + for _, modelID := range selectedModels { + modelID = strings.TrimSpace(modelID) + if modelID != "" { + selected[modelID] = struct{}{} + } + } + seen := make(map[string]struct{}, len(upstreamModels)+len(configuredModels)) + merged := make([]json.RawMessage, 0, len(upstreamModels)+len(configuredModels)) + changed := false + for _, rawModel := range upstreamModels { + var descriptor struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(rawModel, &descriptor); err != nil || strings.TrimSpace(descriptor.Slug) == "" { + if filterBySelection { + changed = true + continue + } + merged = append(merged, rawModel) + continue + } + descriptor.Slug = strings.TrimSpace(descriptor.Slug) + if isCodexDedicatedMediaModel(descriptor.Slug) { + changed = true + continue + } + if filterBySelection { + if _, allowed := selected[descriptor.Slug]; !allowed { + changed = true + continue + } + } + if strings.HasPrefix(descriptor.Slug, codexAutoModelPrefix) { + _, explicitlyEnabled := selected[descriptor.Slug] + explicitlyEnabled = filterBySelection && explicitlyEnabled + if !explicitlyEnabled { + changed = true + continue + } + visibleModel, visibilityChanged, err := codexModelWithVisibility(rawModel, "list") + if err != nil { + return nil, false, err + } + rawModel = visibleModel + changed = changed || visibilityChanged + } + seen[descriptor.Slug] = struct{}{} + merged = append(merged, rawModel) + } + + for _, modelID := range configuredModels { + if isCodexDedicatedMediaModel(modelID) { + continue + } + if filterBySelection { + if _, allowed := selected[modelID]; !allowed { + continue + } + } + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + if _, explicitlyEnabled := selected[modelID]; !filterBySelection || !explicitlyEnabled { + continue + } + } + if _, exists := seen[modelID]; exists { + continue + } + rawModel, err := json.Marshal(newConfiguredCodexModelDescriptor(modelID)) + if err != nil { + return nil, false, err + } + merged = append(merged, rawModel) + seen[modelID] = struct{}{} + changed = true + } + if !changed { + return body, false, nil + } + + rawModels, err := json.Marshal(merged) + if err != nil { + return nil, false, err + } + envelope["models"] = rawModels + mergedBody, err := json.Marshal(envelope) + if err != nil { + return nil, false, err + } + return mergedBody, true, nil +} + +func codexModelWithVisibility(rawModel json.RawMessage, visibility string) (json.RawMessage, bool, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &fields); err != nil { + return nil, false, err + } + var current string + if rawVisibility, ok := fields["visibility"]; ok { + if err := json.Unmarshal(rawVisibility, ¤t); err == nil && current == visibility { + return rawModel, false, nil + } + } + rawVisibility, err := json.Marshal(visibility) + if err != nil { + return nil, false, err + } + fields["visibility"] = rawVisibility + updated, err := json.Marshal(fields) + if err != nil { + return nil, false, err + } + return updated, true, nil } type codexModelsManifestUpstreamError struct { @@ -57,12 +1235,13 @@ func (e *codexModelsManifestUpstreamError) Error() string { return e.err.Error() func (e *codexModelsManifestUpstreamError) Unwrap() error { return e.err } // IsRetryableCodexModelsManifestError reports whether another selected account -// may succeed without changing the request. Configuration and upstream 4xx -// responses, except 429 and ChatGPT-backend 401, are intentionally not -// retried. A manifest 401 from the ChatGPT Codex backend reflects the selected -// OAuth account's upstream token rather than the client request (the client's -// own API key was already validated locally), so a different account may still -// serve the manifest. Custom API key upstreams keep the old no-failover 401 +// may succeed without changing the request. API key upstream 404/405 responses +// mean that the selected account does not expose a model-discovery endpoint, so +// another account may still serve the manifest. Other upstream 4xx responses, +// except 429 and ChatGPT-backend 401, are intentionally not retried. A manifest +// 401 from the ChatGPT Codex backend reflects the selected OAuth account's +// upstream token rather than the client request (the client's own API key was +// already validated locally). Custom API key upstreams keep the no-failover 401 // behavior because their /models auth semantics are not authoritative for the // account. func IsRetryableCodexModelsManifestError(err error) bool { @@ -70,6 +1249,14 @@ func IsRetryableCodexModelsManifestError(err error) bool { return errors.As(err, &upstreamErr) && upstreamErr.retryable } +func isRetryableCodexModelsManifestStatus(statusCode int, useAPIKeyUpstream bool) bool { + return (useAPIKeyUpstream && + (statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed)) || + (statusCode == http.StatusUnauthorized && !useAPIKeyUpstream) || + statusCode == http.StatusTooManyRequests || + (statusCode >= http.StatusInternalServerError && statusCode < 600) +} + func isRetryableCodexModelsManifestTransportError(err error) bool { if err == nil || errors.Is(err, context.Canceled) { return false @@ -195,6 +1382,10 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit { return } + remainingBodyBudget := codexModelsManifestCacheBodyLimit - len(manifest.Body) + if len(manifest.upstreamSourceBody) > remainingBodyBudget { + return + } c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { @@ -243,7 +1434,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc clientVersion = strings.TrimSpace(clientVersion) if clientVersion == "" { - clientVersion = openAICodexProbeVersion + clientVersion = CodexCanonicalClientVersion() } requestEndpoint := chatgptCodexModelsURL @@ -257,14 +1448,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token") } case credAccount.IsOpenAIApiKey(): - baseURL := strings.TrimSpace(credAccount.GetCredential("base_url")) - if baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) { - return nil, infraerrors.New( - http.StatusBadGateway, - "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED", - "Codex models manifest requires a custom API key upstream base URL", - ) - } + baseURL := strings.TrimSpace(credAccount.GetOpenAIBaseURL()) authToken = strings.TrimSpace(credAccount.GetOpenAIApiKey()) if authToken == "" { return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_MISSING", "account has no API key for the Codex models upstream") @@ -305,9 +1489,22 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(headers, credAccount) } headers.Set("Accept", "application/json") - headers.Set("Originator", openai.CodexDefaultOriginator) - headers.Set("Version", clientVersion) - headers.Set("User-Agent", codexCLIUserAgent) + overrideUA := "" + if !useAPIKeyUpstream { + overrideUA = credAccount.GetOpenAIUserAgent() + } + identity := resolveCodexOutboundIdentity(overrideUA) + headers.Set("Originator", identity.originator) + headers.Set("User-Agent", identity.userAgent) + // Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游 + // 门槛时原样使用;否则回退规范版本,避免陈旧 version 触发上游 404(issue #3901)。 + // client_version 查询参数本身始终按客户端原值透传(内容协商语义,契约见 + // TestFetchCodexModelsManifestPassthrough)。 + headerVersion := NormalizeCodexClientVersion(clientVersion) + if headerVersion == "" || CompareVersions(headerVersion, codexUpstreamMinVersion) < 0 { + headerVersion = identity.version + } + headers.Set("Version", headerVersion) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { @@ -458,15 +1655,21 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) resp, err = s.httpUpstream.Do(req, request.proxyURL, request.accountID, request.accountConcurrency) } else { - client, clientErr := httpclient.GetClient(httpclient.Options{ - ProxyURL: request.proxyURL, - Timeout: codexModelsManifestRequestTimeout, - ResponseHeaderTimeout: 10 * time.Second, - }) - if clientErr != nil { - return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr) + handled := false + if s.pluginManager != nil { + resp, handled, err = s.pluginManager.RoundTripOpenAIOAuth(reqCtx, req, request.proxyURL, request.credentialAccount) + } + if !handled { + client, clientErr := httpclient.GetClient(httpclient.Options{ + ProxyURL: request.proxyURL, + Timeout: codexModelsManifestRequestTimeout, + ResponseHeaderTimeout: 10 * time.Second, + }) + if clientErr != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr) + } + resp, err = client.Do(req) } - resp, err = client.Do(req) } if err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -491,22 +1694,30 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont statusCode: resp.StatusCode, headers: resp.Header.Clone(), body: body, - retryable: (resp.StatusCode == http.StatusUnauthorized && !request.useAPIKeyUpstream) || - resp.StatusCode == http.StatusTooManyRequests || - (resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode < 600), + retryable: isRetryableCodexModelsManifestStatus(resp.StatusCode, request.useAPIKeyUpstream), } } - body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit)) + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { return nil, &codexModelsManifestUpstreamError{ err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err), retryable: isRetryableCodexModelsManifestTransportError(err), } } + if int64(len(body)) > bodyLimit { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest response exceeds %d bytes", bodyLimit), + retryable: true, + } + } upstreamBody := body + convertedFromOpenAIModelList := false if request.useAPIKeyUpstream { - body = convertOpenAIModelListToCodexManifest(body) + convertedBody := convertOpenAIModelListToCodexManifest(body) + convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body) + body = convertedBody } if err := validateCodexModelsManifestEnvelope(body); err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -520,6 +1731,22 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } if request.useAPIKeyUpstream { + body, err = completeAPIKeyCodexModelsManifestMetadata( + body, + false, + request.credentialAccount != nil && isOfficialOpenAIModelsBaseURL(request.credentialAccount.GetOpenAIBaseURL()), + ) + if err != nil { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf( + http.StatusBadGateway, + "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST", + "codex models manifest upstream metadata could not be completed: %v", + err, + ), + retryable: true, + } + } body, err = adjustAPIKeyCodexModelsManifest(body) if err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -534,7 +1761,12 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } etag := resp.Header.Get("ETag") - manifest := &CodexModelsManifest{Body: body, ETag: etag} + manifest := &CodexModelsManifest{ + Body: body, + ETag: etag, + upstreamSourceBody: append([]byte(nil), upstreamBody...), + convertedFromOpenAIModelList: convertedFromOpenAIModelList, + } if request.useAPIKeyUpstream { manifest.upstreamETag = etag if !bytes.Equal(body, upstreamBody) { @@ -549,6 +1781,12 @@ func codexModelsManifestBodyETag(body []byte) string { return fmt.Sprintf(`"%x"`, sum) } +// CodexModelsManifestETag returns the strong ETag for a generated client +// catalog. It is based on the final JSON body after local filtering. +func CodexModelsManifestETag(body []byte) string { + return codexModelsManifestBodyETag(body) +} + var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ "gpt-5.6-sol": {}, "gpt-5.6-terra": {}, @@ -612,9 +1850,8 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { // convertOpenAIModelListToCodexManifest rewrites a standard OpenAI // GET /v1/models response ({"object":"list","data":[{"id":...},...]}) into the -// Codex manifest envelope ({"models":[{"slug":...},...]}) so custom API key -// upstreams that only implement the standard endpoint can serve Codex model -// discovery. Bodies that already carry a top-level models field, are not the +// same complete Codex manifest used by locally generated custom-provider +// catalogs. Bodies that already carry a top-level models field, are not the // standard list shape, or yield no usable model IDs are returned unchanged so // envelope validation reports the original payload. func convertOpenAIModelListToCodexManifest(body []byte) []byte { @@ -635,27 +1872,316 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte { if err := json.Unmarshal(data, &entries); err != nil { return body } - type codexModelEntry struct { - Slug string `json:"slug"` - } - models := make([]codexModelEntry, 0, len(entries)) + modelIDs := make([]string, 0, len(entries)) for _, entry := range entries { id := strings.TrimSpace(entry.ID) if id == "" { continue } - models = append(models, codexModelEntry{Slug: id}) + modelIDs = append(modelIDs, id) } - if len(models) == 0 { + if len(modelIDs) == 0 { return body } - converted, err := json.Marshal(map[string][]codexModelEntry{"models": models}) + converted, err := BuildCodexModelsManifest(modelIDs) if err != nil { return body } return converted } +// completeAPIKeyCodexModelsManifestMetadata fills fields omitted by standard +// OpenAI-compatible /models endpoints. Existing provider metadata always wins; +// only absent or null values are synthesized. +// CompleteAPIKeyCodexModelsManifestForClient fills the complete ModelInfo +// contract immediately before a group-specific API key manifest is returned. +// The shared upstream cache remains independent from local group policy. +func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *CodexModelsManifest, account *Account) error { + if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { + return nil + } + body := manifest.Body + if len(manifest.upstreamSourceBody) > 0 { + body = append([]byte(nil), manifest.upstreamSourceBody...) + if manifest.convertedFromOpenAIModelList { + body = convertOpenAIModelListToCodexManifest(body) + } + } + var err error + body, err = applySyncedAPIKeyCodexModelMetadata(body, account, manifest.convertedFromOpenAIModelList) + if err != nil { + return err + } + body, err = completeAPIKeyCodexModelsManifestMetadata( + body, + true, + isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()), + ) + if err != nil { + return err + } + body, err = adjustAPIKeyCodexModelsManifest(body) + if err != nil { + return err + } + manifest.Body = body + manifest.ETag = codexModelsManifestBodyETag(manifest.Body) + return nil +} + +func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwriteLocalDefaults bool) ([]byte, error) { + snapshot := account.GetUpstreamModelMetadataSnapshot() + if snapshot == nil || len(snapshot.Models) == 0 { + return body, nil + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode JSON object: %w", err) + } + var models []json.RawMessage + if err := json.Unmarshal(envelope["models"], &models); err != nil { + return nil, fmt.Errorf("decode top-level models array: %w", err) + } + + changed := false + for i, rawModel := range models { + var model map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &model); err != nil || model == nil { + continue + } + var slug string + if err := json.Unmarshal(model["slug"], &slug); err != nil { + continue + } + slug = strings.TrimSpace(slug) + metadata, ok := snapshot.Models[slug] + if !ok { + continue + } + + descriptor := newConfiguredCodexModelDescriptor(slug) + applyUpstreamModelMetadataToCodexDescriptor( + &descriptor, + codexModelMetadataOverride{UpstreamModelMetadata: metadata}, + ) + descriptorBody, err := json.Marshal(descriptor) + if err != nil { + return nil, fmt.Errorf("encode synced model %q: %w", slug, err) + } + var syncedFields map[string]json.RawMessage + if err := json.Unmarshal(descriptorBody, &syncedFields); err != nil { + return nil, fmt.Errorf("decode synced model %q: %w", slug, err) + } + + fields := make([]string, 0, 7) + if strings.TrimSpace(metadata.DisplayName) != "" { + fields = append(fields, "display_name") + } + if strings.TrimSpace(metadata.Description) != "" { + fields = append(fields, "description") + } + if metadata.Reasoning != nil { + fields = append(fields, "default_reasoning_level", "supported_reasoning_levels") + } + if len(normalizeCodexInputModalities(metadata.InputModalities)) > 0 { + fields = append(fields, "input_modalities") + } + if metadata.ContextWindow > 0 { + fields = append(fields, "context_window", "max_context_window") + } + + modelChanged := false + for _, field := range fields { + value, exists := syncedFields[field] + if !exists { + continue + } + current, currentExists := model[field] + current = bytes.TrimSpace(current) + if !overwriteLocalDefaults && currentExists && len(current) > 0 && !bytes.Equal(current, []byte("null")) { + continue + } + if bytes.Equal(current, bytes.TrimSpace(value)) { + continue + } + model[field] = value + modelChanged = true + } + if !modelChanged { + continue + } + encoded, err := json.Marshal(model) + if err != nil { + return nil, fmt.Errorf("encode model %q with synced metadata: %w", slug, err) + } + models[i] = encoded + changed = true + } + if !changed { + return body, nil + } + + encodedModels, err := json.Marshal(models) + if err != nil { + return nil, fmt.Errorf("encode models with synced metadata: %w", err) + } + envelope["models"] = encodedModels + updated, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("encode manifest with synced metadata: %w", err) + } + return updated, nil +} + +func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode JSON object: %w", err) + } + var models []json.RawMessage + if err := json.Unmarshal(envelope["models"], &models); err != nil { + return nil, fmt.Errorf("decode top-level models array: %w", err) + } + + changed := false + if officialOpenAI { + filtered := make([]json.RawMessage, 0, len(models)) + for _, rawModel := range models { + var model struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(rawModel, &model); err != nil || strings.TrimSpace(model.Slug) == "" { + filtered = append(filtered, rawModel) + continue + } + if !isOfficialOpenAICodexCatalogModel(model.Slug) { + changed = true + continue + } + filtered = append(filtered, rawModel) + } + models = filtered + } + for i, rawModel := range models { + var model map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &model); err != nil || model == nil { + continue + } + var slug string + if err := json.Unmarshal(model["slug"], &slug); err != nil { + continue + } + slug = strings.TrimSpace(slug) + if slug == "" { + continue + } + + completeDescriptor := completeAll || isDeepSeekCodexModel(slug) + forceOfficialImage := officialOpenAI && isOpenAICodexImageInputModel(slug) + if !completeDescriptor && !forceOfficialImage { + continue + } + + descriptor := newConfiguredCodexModelDescriptor(slug) + if forceOfficialImage { + descriptor.InputModalities = []string{"text", "image"} + descriptor.SupportsImageDetailOriginal = true + } + defaultBody, err := json.Marshal(descriptor) + if err != nil { + return nil, fmt.Errorf("encode default model %q: %w", slug, err) + } + var defaults map[string]json.RawMessage + if err := json.Unmarshal(defaultBody, &defaults); err != nil { + return nil, fmt.Errorf("decode default model %q: %w", slug, err) + } + + modelChanged := false + if completeDescriptor { + merged, err := mergeMissingCodexModelFields(model, defaults) + if err != nil { + return nil, fmt.Errorf("complete model %q: %w", slug, err) + } + modelChanged = merged + } + if forceOfficialImage { + modalities, err := json.Marshal([]string{"text", "image"}) + if err != nil { + return nil, fmt.Errorf("encode input modalities for model %q: %w", slug, err) + } + if !bytes.Equal(bytes.TrimSpace(model["input_modalities"]), modalities) { + model["input_modalities"] = modalities + modelChanged = true + } + imageDetailOriginal := json.RawMessage("true") + if !bytes.Equal(bytes.TrimSpace(model["supports_image_detail_original"]), imageDetailOriginal) { + model["supports_image_detail_original"] = imageDetailOriginal + modelChanged = true + } + } + if !modelChanged { + continue + } + encoded, err := json.Marshal(model) + if err != nil { + return nil, fmt.Errorf("encode completed model %q: %w", slug, err) + } + models[i] = encoded + changed = true + } + if !changed { + return body, nil + } + + encodedModels, err := json.Marshal(models) + if err != nil { + return nil, fmt.Errorf("encode top-level models array: %w", err) + } + envelope["models"] = encodedModels + completed, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("encode JSON object: %w", err) + } + return completed, nil +} + +func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) (bool, error) { + changed := false + for key, defaultValue := range defaults { + currentValue, exists := current[key] + if !exists || (bytes.Equal(bytes.TrimSpace(currentValue), []byte("null")) && + !bytes.Equal(bytes.TrimSpace(defaultValue), []byte("null"))) { + current[key] = defaultValue + changed = true + continue + } + + var currentObject map[string]json.RawMessage + var defaultObject map[string]json.RawMessage + if err := json.Unmarshal(currentValue, ¤tObject); err != nil || currentObject == nil { + continue + } + if err := json.Unmarshal(defaultValue, &defaultObject); err != nil || defaultObject == nil { + continue + } + nestedChanged, err := mergeMissingCodexModelFields(currentObject, defaultObject) + if err != nil { + return false, err + } + if !nestedChanged { + continue + } + mergedValue, err := json.Marshal(currentObject) + if err != nil { + return false, fmt.Errorf("encode field %q: %w", key, err) + } + current[key] = mergedValue + changed = true + } + return changed, nil +} + func validateCodexModelsManifestEnvelope(body []byte) error { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { @@ -696,6 +2222,20 @@ func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string return fmt.Sprintf("%x", hasher.Sum(nil)) } +func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifest { + if manifest == nil { + return nil + } + cloned := *manifest + if manifest.Body != nil { + cloned.Body = append([]byte(nil), manifest.Body...) + } + if manifest.upstreamSourceBody != nil { + cloned.upstreamSourceBody = append([]byte(nil), manifest.upstreamSourceBody...) + } + return &cloned +} + func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest { if manifest == nil { return nil @@ -703,7 +2243,7 @@ func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch str if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true} } - return manifest + return cloneCodexModelsManifest(manifest) } func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { @@ -728,6 +2268,12 @@ func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { return false } +// CodexModelsManifestETagMatches applies If-None-Match semantics to a Codex +// catalog ETag, including weak and comma-separated validators. +func CodexModelsManifestETagMatches(ifNoneMatch, etag string) bool { + return codexModelsManifestETagMatches(ifNoneMatch, etag) +} + func isOfficialOpenAIModelsBaseURL(raw string) bool { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 585ad95d5e..77a65cfb38 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1,7 +1,9 @@ package service import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -27,6 +29,1165 @@ type codexModelsHTTPUpstreamStub struct { do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) } +type codexModelsVisibilityAccountRepo struct { + AccountRepository + byGroup map[int64][]Account +} + +func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { + accounts := r.byGroup[groupID] + return append([]Account(nil), accounts...), nil +} + +func (r codexModelsVisibilityAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) { + accounts := r.byGroup[groupID] + return append([]Account(nil), accounts...), nil +} + +type countingCodexModelsAccountRepo struct { + AccountRepository + accounts []Account + err error + listByGroupErr error + calls atomic.Int32 +} + +func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) { + r.calls.Add(1) + if r.err != nil { + return nil, r.err + } + return append([]Account(nil), r.accounts...), nil +} + +func (r *countingCodexModelsAccountRepo) ListByGroup(_ context.Context, _ int64) ([]Account, error) { + if r.listByGroupErr != nil { + return nil, r.listByGroupErr + } + return append([]Account(nil), r.accounts...), nil +} + +type splitCodexModelsAccountRepo struct { + AccountRepository + schedulable map[int64][]Account + catalog map[int64][]Account +} + +func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { + return append([]Account(nil), r.schedulable[groupID]...), nil +} + +func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) { + return append([]Account(nil), r.catalog[groupID]...), nil +} + +func newCodexCatalogMappedAccount( + id int64, + target string, + displayName string, + levels []string, + modalities []string, + contextWindow int64, + schedulable bool, + extraMapping map[string]any, +) Account { + reasoning := true + mapping := map[string]any{"my-coder": target} + for key, value := range extraMapping { + mapping[key] = value + } + account := Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: schedulable, + Credentials: map[string]any{ + "base_url": fmt.Sprintf("https://provider-%d.example/v1", id), + "model_mapping": mapping, + }, + } + models := map[string]UpstreamModelMetadata{ + target: { + ID: target, + DisplayName: displayName, + Description: displayName + " upstream", + Reasoning: &reasoning, + SupportedReasoningLevels: levels, + InputModalities: modalities, + ContextWindow: contextWindow, + }, + } + for _, value := range extraMapping { + exclusive, _ := value.(string) + if exclusive == "" || exclusive == target { + continue + } + models[exclusive] = UpstreamModelMetadata{ + ID: exclusive, + DisplayName: "Exclusive Model", + Description: "Only mapped on the unschedulable account", + Reasoning: &reasoning, + SupportedReasoningLevels: []string{"high"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 1_000_000, + } + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: models}) + return account +} + +func TestFilterCodexModelIDsForGroupOmitsWildcardKeys(t *testing.T) { + t.Parallel() + + got := FilterCodexModelIDsForGroup( + []string{"deepseek-v4-pro", "foo-*", " bar-* ", "gpt-5.5"}, + &Group{Platform: PlatformDeepseek}, + ) + require.Equal(t, []string{"deepseek-v4-pro", "gpt-5.5"}, got) +} + +func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any { + t.Helper() + + var envelope struct { + Models []map[string]any `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + return envelope.Models +} + +func codexManifestModelSlugs(t *testing.T, body []byte) []string { + t.Helper() + + models := decodeCodexManifestModels(t, body) + slugs := make([]string, 0, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + slugs = append(slugs, slug) + } + return slugs +} + +func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) { + t.Helper() + + require.Equal(t, slug, model["slug"]) + require.NotEmpty(t, model["display_name"]) + require.NotEmpty(t, model["description"]) + require.Equal(t, "unified_exec", model["shell_type"]) + require.Equal(t, "list", model["visibility"]) + require.Equal(t, true, model["supported_in_api"]) + require.NotNil(t, model["priority"]) + require.Equal(t, []any{}, model["additional_speed_tiers"]) + require.Equal(t, []any{}, model["service_tiers"]) + require.Contains(t, model, "default_service_tier") + require.Contains(t, model, "availability_nux") + require.Contains(t, model, "upgrade") + require.Contains(t, model, "default_verbosity") + require.Contains(t, model, "apply_patch_tool_type") + require.Contains(t, model, "auto_compact_token_limit") + require.Contains(t, model, "comp_hash") + require.Contains(t, model, "auto_review_model_override") + require.Contains(t, model, "model_specialty") + require.Contains(t, model, "tool_mode") + require.Contains(t, model, "multi_agent_version") + require.Equal(t, true, model["supports_reasoning_summary_parameter"]) + require.Contains(t, model, "include_skills_usage_instructions") + require.Contains(t, model, "include_plugin_usage_instructions") + require.Contains(t, model, "include_apps_usage_instructions") + require.Contains(t, model, "supports_image_detail_original") + require.Contains(t, model, "node_repl_auto_review_required") + require.Contains(t, model, "node_repl_disabled") + require.Contains(t, model, "truncation_policy") + require.Contains(t, model, "supports_parallel_tool_calls") + require.Contains(t, model, "experimental_supported_tools") + modelMessages, ok := model["model_messages"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, modelMessages["instructions_template"]) + for _, key := range []string{ + "instructions_variables", + "approvals", + "collaboration_modes", + "auto_review", + "permissions", + "multi_agent", + "token_budget", + "guardian_v2", + } { + require.Contains(t, modelMessages, key) + } +} + +func effortsFromManifestModel(t *testing.T, model map[string]any) []string { + t.Helper() + + levels, ok := model["supported_reasoning_levels"].([]any) + require.True(t, ok) + efforts := make([]string, 0, len(levels)) + for _, rawLevel := range levels { + level, ok := rawLevel.(map[string]any) + require.True(t, ok) + effort, ok := level["effort"].(string) + require.True(t, ok) + efforts = append(efforts, effort) + } + return efforts +} + +func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) { + t.Parallel() + + deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName) + require.Equal(t, int64(1_000_000), deepSeek.ContextWindow) + require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow) + require.NotNil(t, deepSeek.DefaultReasoningLevel) + require.Equal(t, "high", *deepSeek.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, + }, deepSeek.SupportedReasoningLevels) + require.True(t, deepSeek.SupportsParallelToolCalls) + require.Equal(t, []string{"text"}, deepSeek.InputModalities) + + grok := newConfiguredCodexModelDescriptor("grok-4.6") + require.Equal(t, "Grok 4.6", grok.DisplayName) + require.Equal(t, int64(500_000), grok.ContextWindow) + require.Equal(t, int64(500_000), grok.MaxContextWindow) + require.NotNil(t, grok.DefaultReasoningLevel) + require.Equal(t, "high", *grok.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels)) + require.True(t, grok.SupportsParallelToolCalls) + require.Equal(t, []string{"text"}, grok.InputModalities) + require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"}) + + grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest") + require.Equal(t, "Grok 4.6", grokAlias.DisplayName) + require.NotNil(t, grokAlias.DefaultReasoningLevel) + require.Equal(t, "high", *grokAlias.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels)) + + grok45 := newConfiguredCodexModelDescriptor("grok-4.5") + require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels)) + + grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning") + require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName) + require.NotNil(t, grokNonReasoning.DefaultReasoningLevel) + require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, grokNonReasoning.SupportedReasoningLevels) + + claude := newConfiguredCodexModelDescriptor("claude-opus-4-6") + require.Equal(t, "Claude Opus 4.6", claude.DisplayName) + require.NotNil(t, claude.DefaultReasoningLevel) + require.Equal(t, "medium", *claude.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels)) + require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"}) + require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.True(t, claude.SupportsParallelToolCalls) + + claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels)) + + providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6") + require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName) + require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels)) + + claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001") + require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName) + require.NotNil(t, claudeHaiku.DefaultReasoningLevel) + require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels)) + + gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol") + require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName) + require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description) + require.NotNil(t, gpt56.DefaultReasoningLevel) + require.Equal(t, "low", *gpt56.DefaultReasoningLevel) + require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels)) + require.True(t, gpt56.SupportsParallelToolCalls) + require.True(t, gpt56.SupportVerbosity) + require.Equal(t, []string{"text"}, gpt56.InputModalities) + require.Equal(t, int64(872_000), gpt56.MaxContextWindow) + require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy) + require.NotNil(t, gpt56.DefaultVerbosity) + require.Equal(t, "low", *gpt56.DefaultVerbosity) + require.True(t, gpt56.SupportsReasoningSummaryParameter) + require.Equal(t, "none", gpt56.DefaultReasoningSummary) + + gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels)) + require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel) + + gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") + require.Equal(t, "GPT-5.5", gpt55.DisplayName) + require.NotNil(t, gpt55.DefaultReasoningLevel) + require.Equal(t, "medium", *gpt55.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels)) + require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"}) + require.NotNil(t, gpt55.DefaultVerbosity) + require.Equal(t, "low", *gpt55.DefaultVerbosity) + + gpt4o := newConfiguredCodexModelDescriptor("gpt-4o") + require.Equal(t, "gpt-4o", gpt4o.DisplayName) + require.NotNil(t, gpt4o.DefaultReasoningLevel) + require.Equal(t, "none", *gpt4o.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels)) + require.True(t, gpt4o.SupportsParallelToolCalls) + + image := newConfiguredCodexModelDescriptor("gpt-image-2") + require.Equal(t, "gpt-image-2", image.DisplayName) + require.NotNil(t, image.DefaultReasoningLevel) + require.Equal(t, "none", *image.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels)) + + custom := newConfiguredCodexModelDescriptor("company-coding-model") + require.Equal(t, "company-coding-model", custom.DisplayName) + require.Equal(t, int64(272_000), custom.ContextWindow) + require.NotNil(t, custom.DefaultReasoningLevel) + require.Equal(t, "none", *custom.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, custom.SupportedReasoningLevels) + require.False(t, custom.SupportsParallelToolCalls) + require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate) + require.Equal(t, "auto", custom.DefaultReasoningSummary) + require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy) +} + +func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string { + efforts := make([]string, 0, len(levels)) + for _, level := range levels { + efforts = append(efforts, level.Effort) + } + return efforts +} + +// Scenario: 无推理模型可直接选中。 +func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{"company-coding-model"}) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "none", models[0]["default_reasoning_level"]) + levels, ok := models[0]["supported_reasoning_levels"].([]any) + require.True(t, ok) + require.Len(t, levels, 1) + firstLevel, ok := levels[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "none", firstLevel["effort"]) +} + +// Scenario: 已知推理模型保留真实档位。 +func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"}) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "low", models[0]["default_reasoning_level"]) + levels, ok := models[0]["supported_reasoning_levels"].([]any) + require.True(t, ok) + require.Len(t, levels, 6) + firstLevel, ok := levels[0].(map[string]any) + require.True(t, ok) + require.NotEqual(t, "none", firstLevel["effort"]) +} + +// Scenario: 专用图片生成模型不进入 Codex 主模型目录。 +func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "grok-4.6", + "gpt-image-1", + "gpt-image-1.5", + "gpt-image-2", + "openai/gpt-image-2", + "gemini-2.5-flash-image", + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image", + "google/gemini-3-pro-image", + "google/models/gemini-2.5-flash-image-preview", + "grok-imagine-image", + "grok-imagine-video", + "xai/grok-imagine-image-quality", + "grok-4.5", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + slugs := make([]string, 0, len(models)) + for _, model := range models { + slug, _ := model["slug"].(string) + slugs = append(slugs, slug) + } + require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs) +} + +func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) { + t.Parallel() + + const groupID int64 = 701 + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 1, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token", + }, + }}, + }}, + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"grok-4.5"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.Equal(t, "Grok 4.5", models[0]["display_name"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) +} + +func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) { + t.Parallel() + + const groupID int64 = 702 + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }}, + }}, + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"gpt-5.6-sol"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + accounts []Account + modalities []any + }{ + { + name: "official Grok 4.6", + model: "grok-4.6", + accounts: []Account{{ + ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official Grok Build vision host", + model: "grok-build-0.1", + accounts: []Account{{ + ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official Grok 4.20 vision model", + model: "grok-4.20-0309-reasoning", + accounts: []Account{{ + ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "Grok 3 Mini is text only", + model: "grok-3-mini", + accounts: []Account{{ + ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text"}, + }, + { + name: "Grok Composer has only Chat image bridge", + model: "grok-composer-2.5-fast", + accounts: []Account{{ + ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text"}, + }, + { + name: "custom Grok host", + model: "grok-4.5", + accounts: []Account{{ + ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example.test/v1"}, + }}, + modalities: []any{"text"}, + }, + { + name: "malformed Grok host", + model: "grok-4.5", + accounts: []Account{{ + ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "::invalid::url"}, + }}, + modalities: []any{"text"}, + }, + { + name: "mixed official and custom Grok candidates", + model: "grok-4.5", + accounts: []Account{ + {ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth}, + { + ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example.test/v1"}, + }, + }, + modalities: []any{"text"}, + }, + { + name: "DeepSeek V4", + model: "deepseek-v4-pro", + accounts: []Account{{ + ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text"}, + }, + { + name: "official OpenAI API key", + model: "gpt-5.6-sol", + accounts: []Account{{ + ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official OpenAI legacy text model", + model: "gpt-3.5-turbo", + accounts: []Account{{ + ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text"}, + }, + { + name: "custom OpenAI-compatible host", + model: "gpt-5.6-sol", + accounts: []Account{{ + ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"}, + }}, + modalities: []any{"text"}, + }, + } + + for i, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + groupID := int64(710 + i) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: tt.accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{tt.model}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, tt.modalities, models[0]["input_modalities"]) + }) + } +} + +func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) { + t.Parallel() + + const groupID int64 = 730 + routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{ + ID: 1, + GroupID: groupID, + PublicModel: "vision-alias", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformGrok, + UpstreamModel: "grok-4.5", + Endpoint: CompositeRouteEndpointResponses, + Enabled: true, + }}} + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}}, + }}, + compositeResolver: NewCompositeRouteResolver(routeRepo), + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) { + t.Parallel() + + const groupID int64 = 731 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 21, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{"vision-alias": "grok-4.5"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +// Scenario: a Composite exact alias inherits metadata from its unique mapped target model. +func TestBuildCodexModelsManifestForGroupUsesMappedTargetMetadataForCompositeAlias(t *testing.T) { + t.Parallel() + + const groupID int64 = 733 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 23, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"reasoning-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "reasoning-alias", models[0]["slug"]) + require.Equal(t, "reasoning-alias", models[0]["display_name"]) + require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromManifestModel(t, models[0])) +} + +// Scenario: conflicting targets on the same platform keep the public alias but do not guess capabilities. +func TestBuildCodexModelsManifestForGroupUsesSafeFallbackForConflictingAliasTargets(t *testing.T) { + t.Parallel() + + const groupID int64 = 734 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + { + ID: 24, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"shared-alias": "claude-opus-4-8"}, + }, + }, + { + ID: 25, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"shared-alias": "claude-haiku-4-5-20251001"}, + }, + }, + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"shared-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "shared-alias", models[0]["slug"]) + require.Equal(t, "shared-alias", models[0]["display_name"]) + require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"]) + require.Empty(t, effortsFromManifestModel(t, models[0])) +} + +// Scenario: a media-only target remains hidden even when exposed through an ordinary alias. +func TestBuildCodexModelsManifestForGroupOmitsDedicatedMediaTargetAlias(t *testing.T) { + t.Parallel() + + const groupID int64 = 735 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 26, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"creative-alias": "gpt-image-2"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"creative-alias"}, + ) + require.NoError(t, err) + require.Empty(t, decodeCodexManifestModels(t, body)) +} + +func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { + t.Parallel() + + const groupID int64 = 732 + repo := &countingCodexModelsAccountRepo{accounts: []Account{{ + ID: 22, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "vision-alias-a": "grok-4.5", + "vision-alias-b": "grok-4.6", + }, + }, + }}} + svc := &GatewayService{accountRepo: repo} + _, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) +} + +func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) { + t.Parallel() + + repo := &countingCodexModelsAccountRepo{} + svc := &GatewayService{accountRepo: repo} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: 733, Platform: PlatformDeepseek}, + "", + []string{"deepseek-v4-pro"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) { + t.Parallel() + + repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")} + svc := &GatewayService{accountRepo: repo} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: 734, Platform: PlatformComposite}, + "", + []string{"gpt-5.6-sol", "grok-4.5"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 2) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.Equal(t, []any{"text"}, models[1]["input_modalities"]) +} + +func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) { + t.Parallel() + + const groupID int64 = 71 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + 72: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"other-group-model": "upstream-model"}, + }, + }, + }, + }, + }} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`), + } + + err := svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 2) + require.Equal(t, "gpt-5.6", models[0]["slug"]) + require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro") + require.EqualValues(t, 1_000_000, models[1]["context_window"]) + require.EqualValues(t, 1_000_000, models[1]["max_context_window"]) + require.Equal(t, "high", models[1]["default_reasoning_level"]) + require.Len(t, models[1]["supported_reasoning_levels"], 3) + require.NotContains(t, string(manifest.Body), "other-group-model") + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +// Scenario: OpenAI 分组存在账号模型配置时直接生成本地 Codex 清单。 +func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) { + t.Parallel() + + const groupID int64 = 77 + reasoning := true + arkAccount := Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "glm-5.3": "glm-5.3", + "gpt-image-2": "gpt-image-2", + }, + }, + } + arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "glm-5.3": { + ID: "glm-5.3", + DisplayName: "GLM 5.3", + Description: "Ark coding model", + Reasoning: &reasoning, + DefaultReasoningLevel: "medium", + SupportedReasoningLevels: []string{"low", "medium", "high"}, + InputModalities: []string{"text"}, + ContextWindow: 1_000_000, + }, + }}) + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }, + arkAccount, + }, + }, + }} + group := &Group{ID: groupID, Platform: PlatformOpenAI} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "") + require.NoError(t, err) + require.True(t, configured) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "glm-5.3", models[0]["slug"]) + require.Equal(t, "GLM 5.3", models[0]["display_name"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "medium", models[0]["default_reasoning_level"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + + notModified, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + group, + "W/"+manifest.ETag, + ) + require.NoError(t, err) + require.True(t, configured) + require.True(t, notModified.NotModified) + require.Empty(t, notModified.Body) + require.Equal(t, manifest.ETag, notModified.ETag) +} + +// Scenario: OpenAI 通配映射展开组内精确选择,但不发布通配符 slug。 +func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWildcardMapping(t *testing.T) { + t.Parallel() + + const groupID int64 = 80 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: {{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"}, + }, + }}, + }, + }} + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"gpt-5.6"}, + }, + } + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "") + require.NoError(t, err) + require.True(t, configured) + require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body)) + require.NotContains(t, string(manifest.Body), "gpt-*") +} + +// Scenario: OpenAI 配置目录对暂时不可调度账号取能力交集,且不发布其独有模型。 +func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAccounts(t *testing.T) { + t.Parallel() + + const groupID int64 = 79 + schedulable := newCodexCatalogMappedAccount( + 41, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + ) + unschedulable := newCodexCatalogMappedAccount( + 42, + "glm-5.3", + "GLM 5.3", + []string{"low", "medium", "high"}, + []string{"text"}, + 272_000, + false, + map[string]any{"exclusive-model": "exclusive-upstream"}, + ) + svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {schedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + }} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + ) + require.NoError(t, err) + require.True(t, configured) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "my-coder", models[0]["slug"]) + require.Equal(t, "my-coder", models[0]["display_name"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 272_000, models[0]["context_window"]) +} + +// Scenario: 没有管理员模型配置时保留现有上游发现路径。 +func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) { + t.Parallel() + + const groupID int64 = 78 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: {{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}}, + }, + }} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + ) + require.NoError(t, err) + require.False(t, configured) + require.Nil(t, manifest) +} + +func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) { + t.Parallel() + + const groupID int64 = 74 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + )) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "gpt-5.6", models[0]["slug"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +// Scenario: OpenAI 账号映射不启用 Auto Review。 +func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) { + t.Parallel() + + const groupID int64 = 75 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + openai.CodexUsageProbeModel: openai.CodexUsageProbeModel, + }, + }, + }, + }, + }, + }} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + )) + require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body)) +} + +// Scenario: 启用的分组自定义列表允许 Auto Review。 +func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) { + t.Parallel() + + const groupID int64 = 76 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{openai.CodexUsageProbeModel}, + }, + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body)) +} + +func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) { + t.Parallel() + + const groupID int64 = 73 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-4-pro": "deepseek-v4-pro", + "hidden-alias": "hidden-upstream", + }, + }, + }, + }, + }, + }} + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"deepseek-4-pro"}, + }, + } + upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`) + manifest := &CodexModelsManifest{Body: upstreamBody} + + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro") + + finalETag := manifest.ETag + second := &CodexModelsManifest{Body: upstreamBody} + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag)) + require.True(t, second.NotModified) + require.Empty(t, second.Body) + require.Equal(t, finalETag, second.ETag) +} + type codexModelsBlockingBody struct { ctx context.Context readStarted chan struct{} @@ -122,6 +1283,35 @@ func TestIsRetryableCodexModelsManifestTransportError(t *testing.T) { } } +func TestIsRetryableCodexModelsManifestStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + useAPIKeyUpstream bool + retryable bool + }{ + {name: "api key 404", statusCode: http.StatusNotFound, useAPIKeyUpstream: true, retryable: true}, + {name: "api key 405", statusCode: http.StatusMethodNotAllowed, useAPIKeyUpstream: true, retryable: true}, + {name: "oauth 404", statusCode: http.StatusNotFound}, + {name: "oauth 405", statusCode: http.StatusMethodNotAllowed}, + {name: "api key 401", statusCode: http.StatusUnauthorized, useAPIKeyUpstream: true}, + {name: "oauth 401", statusCode: http.StatusUnauthorized, retryable: true}, + {name: "api key 400", statusCode: http.StatusBadRequest, useAPIKeyUpstream: true}, + {name: "api key 403", statusCode: http.StatusForbidden, useAPIKeyUpstream: true}, + {name: "rate limited", statusCode: http.StatusTooManyRequests, retryable: true}, + {name: "server error", statusCode: http.StatusServiceUnavailable, retryable: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.retryable, isRetryableCodexModelsManifestStatus(tt.statusCode, tt.useAPIKeyUpstream)) + }) + } +} + func newCodexModelsAPIKeyTestService(upstream HTTPUpstream) *OpenAIGatewayService { return &OpenAIGatewayService{ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{ @@ -348,8 +1538,8 @@ func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if gotClientVersion != openAICodexProbeVersion { - t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion) + if gotClientVersion != CodexCanonicalClientVersion() { + t.Errorf("default client_version: got %q, want %q", gotClientVersion, CodexCanonicalClientVersion()) } } @@ -406,7 +1596,7 @@ func TestFetchCodexModelsManifestMissingToken(t *testing.T) { } func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { - manifestBody := `{"models":[{"slug":"gpt-5.6"}]}` + manifestBody := `{"models":[{"slug":"deepseek-v4-pro"}]}` var gotRequest *http.Request var gotProxyURL string var gotAccountID int64 @@ -452,9 +1642,9 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator")) } if gotRequest.Header.Get("Version") != "0.144.0" { - t.Errorf("version header: got %q", gotRequest.Header.Get("Version")) + t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version")) } - if gotRequest.Header.Get("User-Agent") != codexCLIUserAgent { + if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() { t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent")) } if gotRequest.Header.Get("chatgpt-account-id") != "" { @@ -463,12 +1653,48 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 { t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency) } - if string(manifest.Body) != manifestBody { - t.Errorf("body not passed through verbatim: got %q", manifest.Body) - } - if manifest.ETag != `W/"api-key-manifest"` { - t.Errorf("etag not passed through: got %q", manifest.ETag) - } + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag) +} + +// Scenario: 完整上游清单没有 ETag 时,最终正文仍生成强 ETag 并支持 304。 +func TestFetchCodexModelsManifestAPIKeyCompleteBodyWithoutUpstreamETagUsesFinalBodyETag(t *testing.T) { + completeBody, err := BuildCodexModelsManifest([]string{"custom-complete-model"}) + require.NoError(t, err) + + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(completeBody)), + }, nil + }} + svc := newCodexModelsAPIKeyTestService(upstream) + svc.accountRepo = codexModelsVisibilityAccountRepo{} + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + group := &Group{ID: 82, Platform: PlatformOpenAI} + + first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, first, "")) + require.Equal(t, codexModelsManifestBodyETag(first.Body), first.ETag) + require.NotEmpty(t, first.ETag) + + second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account)) + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, first.ETag)) + require.True(t, second.NotModified) + require.Empty(t, second.Body) + require.Equal(t, int32(1), calls.Load()) } func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) { @@ -493,13 +1719,224 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin if err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"},{"slug":"gpt-5.6-codex"}]}`; got != want { - t.Errorf("converted body: got %q, want %q", got, want) - } + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 2) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6") + requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex") require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) require.Equal(t, `W/"openai-list"`, manifest.upstreamETag) } +func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) { + upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}` + + converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody)) + models := decodeCodexManifestModels(t, converted) + + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5") + require.Equal(t, "GPT-5.5", models[0]["display_name"]) + require.Equal(t, "medium", models[0]["default_reasoning_level"]) + require.Len(t, models[0]["supported_reasoning_levels"], 4) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), + } + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6") + require.Equal(t, "Provider supplied", models[0]["description"]) + require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) + modelMessages, ok := models[0]["model_messages"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, modelMessages["instructions_template"]) + require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"]) + require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(manifest.Body, &envelope)) + require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"]) +} + +// Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。 +func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) { + t.Parallel() + + reasoning := true + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "future-reasoner": { + ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability", + Reasoning: &reasoning, DefaultReasoningLevel: "ultra", + SupportedReasoningLevels: []string{"low", "high", "ultra"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 999_000, + }, + }}) + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"future-reasoner","object":"model"}]}`)), + }, nil + }} + svc := newCodexModelsAPIKeyTestService(upstream) + + manifest, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "Future Reasoner", models[0]["display_name"]) + require.Equal(t, "Synced upstream capability", models[0]["description"]) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"low", "high", "ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 999_000, models[0]["context_window"]) + require.EqualValues(t, 999_000, models[0]["max_context_window"]) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWithoutOverwritingExplicitMetadata(t *testing.T) { + t.Parallel() + + reasoning := true + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "provider-model": { + ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description", + Reasoning: &reasoning, DefaultReasoningLevel: "ultra", + SupportedReasoningLevels: []string{"high", "ultra"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 999_000, + }, + }}) + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{ + "slug":"provider-model", + "description":"Provider supplied", + "context_window":64000, + "max_context_window":64000 + }]}`)} + svc := &OpenAIGatewayService{} + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "Synced Display", models[0]["display_name"]) + require.Equal(t, "Provider supplied", models[0]["description"]) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 64_000, models[0]["context_window"]) + require.EqualValues(t, 64_000, models[0]["max_context_window"]) +} + +// Scenario: 原生 manifest 的缺失字段在命中缓存后仍使用账号当前同步快照,而不是缓存中的本地默认值。 +func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedNativeManifest(t *testing.T) { + t.Parallel() + + reasoning := true + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + setSnapshot := func(displayName string, contextWindow int64) { + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "deepseek-v4-pro": { + ID: "deepseek-v4-pro", DisplayName: displayName, + Reasoning: &reasoning, DefaultReasoningLevel: "ultra", + SupportedReasoningLevels: []string{"high", "ultra"}, + InputModalities: []string{"text", "image"}, + ContextWindow: contextWindow, + }, + }}) + } + setSnapshot("Synced DeepSeek", 256_000) + + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "slug":"deepseek-v4-pro", + "description":"Provider supplied" + }]}`)), + }, nil + }} + svc := newCodexModelsAPIKeyTestService(upstream) + + first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + firstModel := decodeCodexManifestModels(t, first.Body)[0] + require.Equal(t, "Synced DeepSeek", firstModel["display_name"]) + require.Equal(t, "Provider supplied", firstModel["description"]) + require.Equal(t, "ultra", firstModel["default_reasoning_level"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, firstModel)) + require.Equal(t, []any{"text", "image"}, firstModel["input_modalities"]) + require.EqualValues(t, 256_000, firstModel["context_window"]) + + setSnapshot("Refreshed DeepSeek", 512_000) + second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account)) + secondModel := decodeCodexManifestModels(t, second.Body)[0] + require.Equal(t, "Refreshed DeepSeek", secondModel["display_name"]) + require.Equal(t, "Provider supplied", secondModel["description"]) + require.EqualValues(t, 512_000, secondModel["context_window"]) + require.Equal(t, int32(1), calls.Load(), "second response should use the cached upstream source body") +} + +func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} + account := newCodexModelsAPIKeyTestAccount("") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 4) + + bySlug := make(map[string]map[string]any, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + bySlug[slug] = model + } + for _, slug := range []string{"gpt-5.6-sol", "gpt-4o"} { + require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"]) + require.Equal(t, true, bySlug[slug]["supports_image_detail_original"]) + } + for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} { + require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"]) + require.Equal(t, false, bySlug[slug]["supports_image_detail_original"]) + } + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} + account := newCodexModelsAPIKeyTestAccount("") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body)) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { tests := []struct { name string @@ -571,17 +2008,27 @@ func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) { require.Equal(t, manifestBody, string(manifest.Body)) } -func TestConvertOpenAIModelListToCodexManifest(t *testing.T) { +func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) { + t.Parallel() + + body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`) + models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body)) + + require.Len(t, models, 2) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash") + requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"]) + require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.EqualValues(t, 1_000_000, models[1]["context_window"]) +} + +func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) { tests := []struct { name string body string want string }{ - { - name: "standard list", - body: `{"object":"list","data":[{"id":"m-1"},{"id":"m-2"}]}`, - want: `{"models":[{"slug":"m-1"},{"slug":"m-2"}]}`, - }, { name: "codex manifest unchanged", body: `{"models":[{"slug":"m-1"}]}`, @@ -623,6 +2070,51 @@ func TestConvertOpenAIModelListToCodexManifest(t *testing.T) { } } +func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) { + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + s.cfg.Gateway.ModelsListReadMaxBytes = 8 + _, err := s.FetchCodexModelsManifest( + context.Background(), + newCodexModelsAPIKeyTestAccount("https://upstream.example"), + "0.144.0", + "", + ) + require.Error(t, err) + require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err)) + require.Contains(t, err.Error(), "response exceeds 8 bytes") + require.True(t, IsRetryableCodexModelsManifestError(err)) +} + +func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) { + manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}` + require.Greater(t, len(manifestBody), 8<<20) + require.Less(t, len(manifestBody), 16<<20) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, manifestBody) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{cfg: &config.Config{}} + s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20 + manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "") + require.NoError(t, err) + require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact") +} + func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { tests := []struct { name string @@ -864,6 +2356,77 @@ func TestFetchCodexModelsManifestAPIKeyFreshCacheHandlesETagLocally(t *testing.T } } +func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) { + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`)), + }, nil + }} + s := newCodexModelsAPIKeyTestService(upstream) + s.accountRepo = codexModelsVisibilityAccountRepo{} + account := newCodexModelsAPIKeyTestAccount("https://upstream.example") + + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Contains(t, string(first.Body), "model-a") + require.Contains(t, string(first.Body), "model-b") + + require.NoError(t, s.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + require.NoError(t, s.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ + ID: 81, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-a"}, + }, + }, + first, + "", + )) + require.Equal(t, []string{"model-a"}, codexManifestModelSlugs(t, first.Body)) + + second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, second.Body)) + require.Equal(t, int32(1), calls.Load()) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, fetchErr) + require.NoError(t, s.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ + ID: 82, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + }, + manifest, + "", + )) + require.Equal(t, []string{"model-b"}, codexManifestModelSlugs(t, manifest.Body)) + }() + } + wg.Wait() + + third, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, third.Body)) + require.Equal(t, int32(1), calls.Load()) +} + func TestFetchCodexModelsManifestAPIKeyCacheKeyIsolatesRequestIdentity(t *testing.T) { var calls atomic.Int32 upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { @@ -920,7 +2483,10 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { calls.Add(1) body := `{"models":[]}` - if strings.Contains(req.URL.Host, "large") { + switch { + case strings.Contains(req.URL.Host, "large-source"): + body = `{"object":"list","data":[{"id":"model-a","padding":"` + strings.Repeat("x", 1<<20) + `"}]}` + case strings.Contains(req.URL.Host, "large"): body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}` } return &http.Response{ @@ -944,8 +2510,12 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. large.ID = 3 fetch(large) fetch(large) - if got := calls.Load(); got != 3 { - t.Fatalf("body-size bounded cache calls: got %d, want 3", got) + largeSource := newCodexModelsAPIKeyTestAccount("https://large-source.example") + largeSource.ID = 4 + fetch(largeSource) + fetch(largeSource) + if got := calls.Load(); got != 5 { + t.Fatalf("body-size bounded cache calls: got %d, want 5", got) } for i := int64(10); i < 75; i++ { @@ -956,14 +2526,14 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. last := newCodexModelsAPIKeyTestAccount("https://bounded.example") last.ID = 74 fetch(last) - if got := calls.Load(); got != 68 { - t.Fatalf("most recent cache entry was not retained: calls=%d, want 68", got) + if got := calls.Load(); got != 70 { + t.Fatalf("most recent cache entry was not retained: calls=%d, want 70", got) } first := newCodexModelsAPIKeyTestAccount("https://bounded.example") first.ID = 10 fetch(first) - if got := calls.Load(); got != 69 { - t.Errorf("oldest cache entry was not evicted: calls=%d, want 69", got) + if got := calls.Load(); got != 71 { + t.Errorf("oldest cache entry was not evicted: calls=%d, want 71", got) } } @@ -1416,7 +2986,7 @@ func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) { } } -func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T) { +func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testing.T) { tests := []struct { name string baseURL string @@ -1428,23 +2998,32 @@ func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { - t.Fatal("official OpenAI API key must not be used as a Codex manifest upstream") - return nil, nil + var gotURL string + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + gotURL = req.URL.String() + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)), + }, nil }}) - _, err := s.FetchCodexModelsManifest( + manifest, err := s.FetchCodexModelsManifest( context.Background(), newCodexModelsAPIKeyTestAccount(tt.baseURL), "0.144.0", "", ) - if err == nil { - t.Fatal("expected unsupported API key upstream error, got nil") - } - if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED" { - t.Errorf("error reason: got %q", infraerrors.Reason(err)) - } + require.NoError(t, err) + parsedURL, parseErr := url.Parse(gotURL) + require.NoError(t, parseErr) + require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname())) + require.Equal(t, "/v1/models", parsedURL.Path) + require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version")) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol") + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) }) } } diff --git a/backend/internal/service/openai_codex_pat_service.go b/backend/internal/service/openai_codex_pat_service.go index dce6785920..fa89aa962d 100644 --- a/backend/internal/service/openai_codex_pat_service.go +++ b/backend/internal/service/openai_codex_pat_service.go @@ -10,7 +10,6 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami" @@ -59,8 +58,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex } req.Header.Set("authorization", "Bearer "+accessToken) req.Header.Set("accept", "application/json") - req.Header.Set("originator", openai.CodexDefaultOriginator) - req.Header.Set("user-agent", codexCLIUserAgent) + ApplyCodexCanonicalAuthIdentity(req.Header) resp, err := client.Do(req) if err != nil { diff --git a/backend/internal/service/openai_codex_pat_service_test.go b/backend/internal/service/openai_codex_pat_service_test.go index e7656868ba..3210ed3062 100644 --- a/backend/internal/service/openai_codex_pat_service_test.go +++ b/backend/internal/service/openai_codex_pat_service_test.go @@ -40,7 +40,7 @@ func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) { require.NoError(t, err) require.Equal(t, "Bearer at-test-token", gotAuthorization) require.Equal(t, openai.CodexDefaultOriginator, gotOriginator) - require.Equal(t, codexCLIUserAgent, gotUserAgent) + require.Equal(t, CodexCanonicalUserAgent(), gotUserAgent) require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode) require.Equal(t, "user@example.com", info.Email) require.Equal(t, "user-123", info.ChatGPTUserID) diff --git a/backend/internal/service/openai_codex_tool_names.go b/backend/internal/service/openai_codex_tool_names.go new file mode 100644 index 0000000000..dfa8deacc0 --- /dev/null +++ b/backend/internal/service/openai_codex_tool_names.go @@ -0,0 +1,455 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + codexReservedPythonToolName = "python" + codexPythonToolAlias = "python__sub2api" + codexToolNameReverseKey = "openai_codex_tool_name_reverse" + codexToolNameSessionKey = "openai_codex_tool_name_session_reverse" +) + +type codexToolNameField struct { + object map[string]any + key string + name string +} + +// aliasOpenAIOAuthReservedToolNames avoids names reserved by the ChatGPT +// Codex backend. It validates every declaration/reference before mutating so +// collisions cannot leave a partially rewritten request. +func aliasOpenAIOAuthReservedToolNames(reqBody map[string]any) (map[string]string, bool, error) { + if reqBody == nil { + return nil, false, nil + } + + fields := collectOpenAIResponsesToolNameFields(reqBody) + owners := make(map[string]string) + reverse := make(map[string]string) + for _, field := range fields { + normalized := aliasOpenAIOAuthReservedToolName(field.name) + original := field.name + if normalized != field.name { + original = strings.TrimSpace(field.name) + } + if previous, exists := owners[normalized]; exists && previous != original { + return nil, false, fmt.Errorf("tool names %q and %q both normalize to %q", previous, original, normalized) + } + owners[normalized] = original + if normalized != field.name { + reverse[normalized] = original + } + } + if len(reverse) == 0 { + return nil, false, nil + } + for _, field := range fields { + if aliased := aliasOpenAIOAuthReservedToolName(field.name); aliased != field.name { + field.object[field.key] = aliased + } + } + return reverse, true, nil +} + +func aliasOpenAIOAuthReservedToolName(name string) string { + trimmed := strings.TrimSpace(name) + if strings.EqualFold(trimmed, codexReservedPythonToolName) { + return codexPythonToolAlias + } + return name +} + +func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNameField { + fields := make([]codexToolNameField, 0, 8) + appendName := func(object map[string]any, key string) { + if object == nil { + return + } + name, ok := object[key].(string) + if !ok || strings.TrimSpace(name) == "" { + return + } + fields = append(fields, codexToolNameField{object: object, key: key, name: name}) + } + var collectTools func(any) + collectTools = func(rawTools any) { + tools, ok := rawTools.([]any) + if !ok { + return + } + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"]))) + if toolType == "function" { + appendName(tool, "name") + if function, ok := tool["function"].(map[string]any); ok { + appendName(function, "name") + } + } + if toolType == "namespace" { + collectTools(tool["tools"]) + } + } + } + collectTools(reqBody["tools"]) + if functions, ok := reqBody["functions"].([]any); ok { + for _, raw := range functions { + function, _ := raw.(map[string]any) + appendName(function, "name") + } + } + if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(reqBody["type"])), "session.update") { + if session, ok := reqBody["session"].(map[string]any); ok { + collectTools(session["tools"]) + } + } + if choice, ok := reqBody["tool_choice"].(map[string]any); ok { + if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "function") { + appendName(choice, "name") + if function, ok := choice["function"].(map[string]any); ok { + appendName(function, "name") + } + } + } + if input, ok := reqBody["input"].([]any); ok { + for _, raw := range input { + item, ok := raw.(map[string]any) + if !ok { + continue + } + typ := strings.ToLower(strings.TrimSpace(firstNonEmptyString(item["type"]))) + if typ == "additional_tools" { + collectTools(item["tools"]) + } + if typ == "function_call" { + appendName(item, "name") + if function, ok := item["function"].(map[string]any); ok { + appendName(function, "name") + } + } + } + } + return fields +} + +func aliasOpenAIOAuthReservedToolNamesBody(body []byte) ([]byte, map[string]string, bool, error) { + if len(body) == 0 || !containsASCIIFold(body, []byte(codexReservedPythonToolName)) { + return body, nil, false, nil + } + var reqBody map[string]any + if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil { + return body, nil, false, fmt.Errorf("decode OAuth reserved tool names: %w", err) + } + reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody) + if err != nil || !changed { + return body, reverse, false, err + } + normalized, err := json.Marshal(reqBody) + if err != nil { + return body, nil, false, fmt.Errorf("encode OAuth reserved tool names: %w", err) + } + return normalized, reverse, true, nil +} + +func containsASCIIFold(haystack, needle []byte) bool { + if len(needle) == 0 || len(haystack) < len(needle) { + return false + } + for i := 0; i <= len(haystack)-len(needle); i++ { + matched := true + for j := range needle { + a, b := haystack[i+j], needle[j] + if a >= 'A' && a <= 'Z' { + a += 'a' - 'A' + } + if b >= 'A' && b <= 'Z' { + b += 'a' - 'A' + } + if a != b { + matched = false + break + } + } + if matched { + return true + } + } + return false +} + +func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) { + if c == nil { + return + } + storeCodexToolNameReverse(c, codexToolNameReverseKey, reverse) + storeCodexToolNameReverse(c, codexToolNameSessionKey, nil) +} + +func storeCodexToolNameReverse(c *gin.Context, key string, reverse map[string]string) { + if c == nil { + return + } + copyMap := make(map[string]string, len(reverse)) + for aliased, original := range reverse { + copyMap[aliased] = original + } + c.Set(key, copyMap) +} + +func mergeCodexToolNameReverse(c *gin.Context, reverse map[string]string) { + if c == nil || len(reverse) == 0 { + return + } + merged := make(map[string]string, len(reverse)+len(codexToolNameReverseFromContext(c))) + for aliased, original := range codexToolNameReverseFromContext(c) { + merged[aliased] = original + } + for aliased, original := range reverse { + merged[aliased] = original + } + storeCodexToolNameReverse(c, codexToolNameReverseKey, merged) +} + +func codexToolNameReverseFromContext(c *gin.Context) map[string]string { + return codexToolNameReverseForKey(c, codexToolNameReverseKey) +} + +func codexToolNameReverseForKey(c *gin.Context, key string) map[string]string { + if c == nil { + return nil + } + raw, ok := c.Get(key) + if !ok { + return nil + } + reverse, _ := raw.(map[string]string) + return reverse +} + +// updateCodexToolNameReverseForWSFrame keeps the active turn isolated from +// session updates that may arrive while that turn is still streaming. +func updateCodexToolNameReverseForWSFrame(c *gin.Context, frame []byte, reverse map[string]string) { + if c == nil { + return + } + eventType := strings.TrimSpace(gjson.GetBytes(frame, "type").String()) + switch eventType { + case "session.update": + if gjson.GetBytes(frame, "session.tools").Exists() { + storeCodexToolNameReverse(c, codexToolNameSessionKey, reverse) + } + case "response.create", "": + active := reverse + if !openAIWSFrameHasExplicitToolDeclarations(frame) { + active = mergeCodexToolNameReverseMaps( + codexToolNameReverseForKey(c, codexToolNameSessionKey), + reverse, + ) + } + storeCodexToolNameReverse(c, codexToolNameReverseKey, active) + } +} + +func openAIWSFrameHasExplicitToolDeclarations(frame []byte) bool { + if gjson.GetBytes(frame, "tools").Exists() { + return true + } + for _, item := range gjson.GetBytes(frame, "input").Array() { + if strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "additional_tools") && item.Get("tools").Exists() { + return true + } + } + return false +} + +func mergeCodexToolNameReverseMaps(base, overlay map[string]string) map[string]string { + merged := make(map[string]string, len(base)+len(overlay)) + for aliased, original := range base { + merged[aliased] = original + } + for aliased, original := range overlay { + merged[aliased] = original + } + return merged +} + +func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte { + if len(data) == 0 || len(reverse) == 0 || !json.Valid(data) { + return data + } + var decoded any + if err := decodeOpenAIJSONUseNumber(data, &decoded); err != nil { + return data + } + if !restoreCodexToolNameFields(decoded, reverse) { + return data + } + restored, err := json.Marshal(decoded) + if err != nil { + return data + } + return restored +} + +func restoreCodexToolNamesFromContext(c *gin.Context, data []byte) []byte { + reverse := codexToolNameReverseFromContext(c) + switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) { + case "session.created", "session.updated": + reverse = codexToolNameReverseForKey(c, codexToolNameSessionKey) + } + return restoreCodexToolNamesInJSON(data, reverse) +} + +func restoreCodexToolNamesFromSSEContext(c *gin.Context, data []byte, eventType string) []byte { + if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "" || strings.TrimSpace(eventType) == "" { + return restoreCodexToolNamesFromContext(c, data) + } + compat := []byte(openAICompatPayloadWithEventType(string(data), eventType)) + restored := restoreCodexToolNamesFromContext(c, compat) + if string(restored) == string(compat) { + return data + } + withoutSyntheticType, err := sjson.DeleteBytes(restored, "type") + if err != nil { + return restored + } + return withoutSyntheticType +} + +func restoreCodexToolNameFields(value any, reverse map[string]string) bool { + root, ok := value.(map[string]any) + if !ok { + return false + } + changed := false + restoreItem := func(raw any) { + item, ok := raw.(map[string]any) + if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(item["type"])), "function_call") { + return + } + name, _ := item["name"].(string) + if original, exists := reverse[name]; exists { + item["name"] = original + changed = true + } + } + restoreOutput := func(raw any) { + output, _ := raw.([]any) + for _, item := range output { + restoreItem(item) + } + } + restoreResponse := func(raw any) { + response, ok := raw.(map[string]any) + if ok { + restoreOutput(response["output"]) + } + } + restoreFunction := func(raw any) { + function, ok := raw.(map[string]any) + if !ok { + return + } + name, _ := function["name"].(string) + if original, exists := reverse[name]; exists { + function["name"] = original + changed = true + } + } + restoreChatToolCalls := func(raw any) { + toolCalls, _ := raw.([]any) + for _, rawCall := range toolCalls { + call, ok := rawCall.(map[string]any) + if !ok { + continue + } + callType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(call["type"]))) + if callType == "" || callType == "function" { + restoreFunction(call["function"]) + } + } + } + restoreMessageContent := func(raw any) { + content, _ := raw.([]any) + for _, rawBlock := range content { + block, ok := rawBlock.(map[string]any) + if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") { + continue + } + name, _ := block["name"].(string) + if original, exists := reverse[name]; exists { + block["name"] = original + changed = true + } + } + } + var restoreTools func(any) + restoreTools = func(raw any) { + tools, _ := raw.([]any) + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"]))) + if toolType == "function" { + name, _ := tool["name"].(string) + if original, exists := reverse[name]; exists { + tool["name"] = original + changed = true + } + } + if toolType == "namespace" { + restoreTools(tool["tools"]) + } + } + } + + eventType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(root["type"]))) + switch eventType { + case "response.output_item.added", "response.output_item.done": + restoreItem(root["item"]) + case "response.created", "response.in_progress", "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + restoreResponse(root["response"]) + case "session.created", "session.updated": + if session, ok := root["session"].(map[string]any); ok { + restoreTools(session["tools"]) + } + } + if _, hasOutput := root["output"]; hasOutput { + restoreOutput(root["output"]) + } + if choices, ok := root["choices"].([]any); ok { + for _, rawChoice := range choices { + choice, ok := rawChoice.(map[string]any) + if !ok { + continue + } + for _, key := range []string{"message", "delta"} { + if message, ok := choice[key].(map[string]any); ok { + restoreChatToolCalls(message["tool_calls"]) + } + } + } + } + restoreMessageContent(root["content"]) + if block, ok := root["content_block"].(map[string]any); ok && strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") { + name, _ := block["name"].(string) + if original, exists := reverse[name]; exists { + block["name"] = original + changed = true + } + } + return changed +} diff --git a/backend/internal/service/openai_codex_tool_names_test.go b/backend/internal/service/openai_codex_tool_names_test.go new file mode 100644 index 0000000000..73b7164deb --- /dev/null +++ b/backend/internal/service/openai_codex_tool_names_test.go @@ -0,0 +1,250 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAliasOpenAIOAuthReservedToolNames_RewritesDeclarationsAndReferences(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "python"}, + map[string]any{"type": "namespace", "name": "code", "tools": []any{ + map[string]any{"type": "function", "name": "shell"}, + }}, + }, + "tool_choice": map[string]any{"type": "function", "name": "python"}, + "input": []any{ + map[string]any{"type": "function_call", "name": "python", "call_id": "fc_1"}, + map[string]any{"type": "additional_tools", "tools": []any{ + map[string]any{"type": "function", "function": map[string]any{"name": "python"}}, + }}, + }, + } + + reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "python", reverse[codexPythonToolAlias]) + tools, ok := reqBody["tools"].([]any) + require.True(t, ok) + require.NotEmpty(t, tools) + firstTool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, firstTool["name"]) + toolChoice, ok := reqBody["tool_choice"].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, toolChoice["name"]) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.NotEmpty(t, input) + firstInput, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, firstInput["name"]) + secondInput, ok := input[1].(map[string]any) + require.True(t, ok) + nestedTools, ok := secondInput["tools"].([]any) + require.True(t, ok) + require.NotEmpty(t, nestedTools) + nestedTool, ok := nestedTools[0].(map[string]any) + require.True(t, ok) + nestedFn, ok := nestedTool["function"].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, nestedFn["name"]) +} + +func TestAliasOpenAIOAuthReservedToolNames_CollisionDoesNotMutate(t *testing.T) { + reqBody := map[string]any{"tools": []any{ + map[string]any{"type": "function", "name": "python"}, + map[string]any{"type": "function", "name": codexPythonToolAlias}, + }} + before, err := json.Marshal(reqBody) + require.NoError(t, err) + + reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody) + require.ErrorContains(t, err, `both normalize to "python__sub2api"`) + require.False(t, changed) + require.Nil(t, reverse) + after, marshalErr := json.Marshal(reqBody) + require.NoError(t, marshalErr) + require.JSONEq(t, string(before), string(after)) +} + +func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.5", + "tools": []any{map[string]any{"type": "function", "name": "PYTHON"}}, + } + + result := applyCodexOAuthTransform(reqBody, true, false) + require.NoError(t, result.Error) + require.Equal(t, "PYTHON", result.ToolNameReverse[codexPythonToolAlias]) + tools, ok := reqBody["tools"].([]any) + require.True(t, ok) + require.NotEmpty(t, tools) + tool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, tool["name"]) + + apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) + require.NoError(t, err) + require.False(t, changed) + require.JSONEq(t, string(apiKeyBody), string(normalized)) +} + +func TestRestoreCodexToolNamesFromContext_HTTPAndWSPayloadShapes(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: "python"}) + + streamEvent := restoreCodexToolNamesFromContext(c, []byte( + `{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"},"note":"python__sub2api"}`, + )) + require.Equal(t, "python", gjson.GetBytes(streamEvent, "item.name").String()) + require.Equal(t, "python__sub2api", gjson.GetBytes(streamEvent, "note").String()) + + nonStreaming := restoreCodexToolNamesFromContext(c, []byte( + `{"id":"resp_1","output":[{"type":"function_call","name":"python__sub2api"}]}`, + )) + require.Equal(t, "python", gjson.GetBytes(nonStreaming, "output.0.name").String()) + + setCodexToolNameReverse(c, nil) + require.JSONEq(t, + `{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`, + string(restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`))), + ) +} + +func TestAliasOpenAIOAuthReservedToolNames_SessionUpdateOnlyTouchesFunctionProtocolNodes(t *testing.T) { + body := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"},{"type":"image_generation","name":"python"},{"type":"namespace","name":"python","tools":[{"type":"function","name":"shell"}]}]},"metadata":{"name":"python"},"sequence":900719925474099312345}`) + + aliased, reverse, changed, err := aliasOpenAIOAuthReservedToolNamesBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "python", reverse[codexPythonToolAlias]) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(aliased, "session.tools.0.name").String()) + require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.1.name").String()) + require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.2.name").String()) + require.Equal(t, "shell", gjson.GetBytes(aliased, "session.tools.2.tools.0.name").String()) + require.Equal(t, "python", gjson.GetBytes(aliased, "metadata.name").String()) + require.Equal(t, "900719925474099312345", gjson.GetBytes(aliased, "sequence").Raw) +} + +func TestRestoreCodexToolNamesInJSON_OnlyTouchesResponseToolCallNodesAndPreservesNumbers(t *testing.T) { + reverse := map[string]string{codexPythonToolAlias: "python"} + body := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"python__sub2api"},{"type":"message","name":"python__sub2api","content":[]}]},"metadata":{"name":"python__sub2api"},"sequence":900719925474099312345}`) + + restored := restoreCodexToolNamesInJSON(body, reverse) + require.Equal(t, "python", gjson.GetBytes(restored, "response.output.0.name").String()) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "response.output.1.name").String()) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String()) + require.Equal(t, "900719925474099312345", gjson.GetBytes(restored, "sequence").Raw) +} + +func TestRestoreCodexToolNamesInJSON_ExplicitHTTPAndSSEToolCallProtocols(t *testing.T) { + reverse := map[string]string{codexPythonToolAlias: "python"} + tests := []struct { + name string + body string + path string + }{ + { + name: "chat http", + body: `{"choices":[{"message":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`, + path: "choices.0.message.tool_calls.0.function.name", + }, + { + name: "chat sse", + body: `{"choices":[{"delta":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`, + path: "choices.0.delta.tool_calls.0.function.name", + }, + { + name: "messages tool use", + body: `{"type":"content_block_start","content":[{"type":"tool_use","name":"python__sub2api"}],"metadata":{"name":"python__sub2api"}}`, + path: "content.0.name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restored := restoreCodexToolNamesInJSON([]byte(tt.body), reverse) + require.Equal(t, "python", gjson.GetBytes(restored, tt.path).String()) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String()) + }) + } +} + +func TestAliasOpenAIOAuthReservedToolNames_PromptCompatibilityRunsFirst(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","prompt":[{"type":"function_call","name":"python","call_id":"fc_1"}],"functions":[{"name":"python"}],"function_call":{"name":"python"},"sequence":900719925474099312345}`) + reqBody, err := getOpenAIRequestBodyMap(nil, body) + require.NoError(t, err) + result := applyCodexOAuthTransform(reqBody, true, false) + require.NoError(t, result.Error) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.NotEmpty(t, input) + firstInput, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, firstInput["name"]) + tools, ok := reqBody["tools"].([]any) + require.True(t, ok) + require.NotEmpty(t, tools) + tool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, tool["name"]) + toolChoice, ok := reqBody["tool_choice"].(map[string]any) + require.True(t, ok) + require.Equal(t, codexPythonToolAlias, toolChoice["name"]) + encoded, err := json.Marshal(reqBody) + require.NoError(t, err) + require.Equal(t, "900719925474099312345", gjson.GetBytes(encoded, "sequence").Raw) +} + +func TestCodexToolNameReverse_WSSessionReplacementDoesNotChangeActiveTurn(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + setCodexToolNameReverse(c, nil) + first := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`) + updateCodexToolNameReverseForWSFrame(c, first, map[string]string{codexPythonToolAlias: "python"}) + + update := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`) + updateCodexToolNameReverseForWSFrame(c, update, nil) + currentOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`)) + require.Equal(t, "python", gjson.GetBytes(currentOutput, "item.name").String()) + sessionEcho := restoreCodexToolNamesFromContext(c, []byte(`{"type":"session.updated","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`)) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(sessionEcho, "session.tools.0.name").String()) + + next := []byte(`{"type":"response.create","input":"next"}`) + updateCodexToolNameReverseForWSFrame(c, next, nil) + nextOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`)) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(nextOutput, "item.name").String()) + + sessionPython := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"}]}}`) + updateCodexToolNameReverseForWSFrame(c, sessionPython, map[string]string{codexPythonToolAlias: "python"}) + explicitLiteral := []byte(`{"type":"response.create","input":[{"type":"additional_tools","tools":[{"type":"function","name":"python__sub2api"}]}]}`) + updateCodexToolNameReverseForWSFrame(c, explicitLiteral, nil) + literalOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`)) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(literalOutput, "item.name").String()) + updateCodexToolNameReverseForWSFrame(c, next, nil) + inheritedOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`)) + require.Equal(t, "python", gjson.GetBytes(inheritedOutput, "item.name").String()) +} + +func TestDecodeOpenAIJSONUseNumberRejectsTrailingDocument(t *testing.T) { + var decoded map[string]any + require.Error(t, decodeOpenAIJSONUseNumber([]byte(`{"name":"python"}{"extra":true}`), &decoded)) +} + +func TestRestoreCodexToolNamesFromSSEContextUsesEventLineTypeWithoutAddingType(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: codexReservedPythonToolName}) + payload := []byte(`{"item":{"type":"function_call","name":"python__sub2api"},"metadata":{"name":"python__sub2api"}}`) + + restored := restoreCodexToolNamesFromSSEContext(c, payload, "response.output_item.done") + + require.Equal(t, codexReservedPythonToolName, gjson.GetBytes(restored, "item.name").String()) + require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String()) + require.False(t, gjson.GetBytes(restored, "type").Exists()) +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index c89fd038d0..fbd14ba967 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -76,6 +76,8 @@ type codexTransformResult struct { Modified bool NormalizedModel string PromptCacheKey string + ToolNameReverse map[string]string + Error error } type codexOAuthTransformOptions struct { @@ -92,26 +94,41 @@ const ( ) func normalizeCodexCallID(id string) string { + return normalizeCodexCallIDForItemType("function_call", id) +} + +func normalizeCodexCallIDForItemType(itemType, id string) string { + prefix := openAIResponsesToolCallIDPrefix(itemType) + "_" candidate := id switch { case id == "": return "" - case strings.HasPrefix(id, "fc"): + case strings.HasPrefix(id, strings.TrimSuffix(prefix, "_")): case strings.HasPrefix(id, "call_"): - candidate = codexCallIDPrefix + strings.TrimPrefix(id, "call_") + candidate = prefix + strings.TrimPrefix(id, "call_") default: - candidate = codexCallIDPrefix + id + candidate = prefix + trimOpenAIResponsesKnownCallIDPrefix(id) } if len(candidate) <= codexCallIDMaxLength { return candidate } - return compactCodexCallID(candidate) + return compactCodexCallIDForItemType(itemType, candidate) } -func compactCodexCallID(id string) string { +func compactCodexCallIDForItemType(itemType, id string) string { + prefix := openAIResponsesToolCallIDPrefix(itemType) + "_" digest := sha256.Sum256([]byte("sub2api:codex-call-id:v1:" + id)) encoded := hex.EncodeToString(digest[:]) - return codexCallIDPrefix + encoded[:codexCallIDMaxLength-len(codexCallIDPrefix)] + return prefix + encoded[:codexCallIDMaxLength-len(prefix)] +} + +func trimOpenAIResponsesKnownCallIDPrefix(id string) string { + for _, prefix := range []string{"fc_", "ctc_", "tsc_"} { + if strings.HasPrefix(id, prefix) { + return strings.TrimPrefix(id, prefix) + } + } + return id } const codexImageGenerationFunctionToolName = "image_gen.imagegen" @@ -124,11 +141,14 @@ const ( ) var openAIChatGPTInternalUnsupportedFields = []string{ + "chat_template_kwargs", "user", "metadata", "prompt_cache_retention", "safety_identifier", "stream_options", + "truncation", + "stop_sequences", } var openAICodexOAuthUnsupportedFields = append([]string{ @@ -149,6 +169,9 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult { result := codexTransformResult{} + if normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody) { + result.Modified = true + } // 工具续链需求会影响存储策略与 input 过滤逻辑。 needsToolContinuation := NeedsToolContinuation(reqBody) @@ -237,6 +260,18 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth if normalizeCodexTools(reqBody) { result.Modified = true } + // Collect aliases only after prompt/functions/function_call compatibility + // has produced the final Responses protocol nodes. Otherwise references + // introduced by those migrations can retain the reserved name. + toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody) + if err != nil { + result.Error = err + return result + } + result.ToolNameReverse = toolNameReverse + if toolNamesChanged { + result.Modified = true + } if normalizeCodexToolChoice(reqBody) { result.Modified = true } @@ -884,6 +919,124 @@ func normalizeOpenAIResponsesImageGenerationTools(reqBody map[string]any) bool { delete(toolMap, "compression") modified = true } + imageModel := strings.ToLower(strings.TrimSpace(firstNonEmptyString(toolMap["model"]))) + if strings.HasPrefix(imageModel, "gpt-image-2") { + if _, ok := toolMap["input_fidelity"]; ok { + delete(toolMap, "input_fidelity") + modified = true + } + } + } + return modified +} + +func normalizeOpenAIResponseFormatSchemas(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + modified := false + normalizeFormat := func(format map[string]any) { + if format == nil || strings.TrimSpace(firstNonEmptyString(format["type"])) != "json_schema" { + return + } + if schema, ok := format["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) { + modified = true + } + if jsonSchema, ok := format["json_schema"].(map[string]any); ok { + if schema, ok := jsonSchema["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) { + modified = true + } + } + } + if text, ok := reqBody["text"].(map[string]any); ok { + if format, ok := text["format"].(map[string]any); ok { + normalizeFormat(format) + } + } + if responseFormat, ok := reqBody["response_format"].(map[string]any); ok { + normalizeFormat(responseFormat) + } + return modified +} + +func normalizeOpenAIResponseJSONSchema(schema map[string]any) bool { + if schema == nil { + return false + } + modified := false + for _, key := range []string{"uniqueItems", "minProperties"} { + if _, exists := schema[key]; exists { + delete(schema, key) + modified = true + } + } + if rawType, exists := schema["type"]; !exists || rawType == nil { + switch { + case schema["properties"] != nil: + schema["type"] = "object" + modified = true + case schema["items"] != nil: + schema["type"] = "array" + modified = true + } + } + if properties, ok := schema["properties"].(map[string]any); ok { + for _, raw := range properties { + if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) { + modified = true + } + } + } + switch items := schema["items"].(type) { + case map[string]any: + if normalizeOpenAIResponseJSONSchema(items) { + modified = true + } + case []any: + for _, raw := range items { + if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) { + modified = true + } + } + } + for _, key := range []string{ + "additionalProperties", + "additionalItems", + "contains", + "not", + "if", + "then", + "else", + "propertyNames", + "unevaluatedProperties", + "unevaluatedItems", + } { + if child, ok := schema[key].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) { + modified = true + } + } + for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} { + children, _ := schema[key].([]any) + for _, raw := range children { + if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) { + modified = true + } + } + } + for _, key := range []string{"$defs", "definitions", "patternProperties", "dependentSchemas"} { + children, _ := schema[key].(map[string]any) + for _, raw := range children { + if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) { + modified = true + } + } + } + if dependencies, ok := schema["dependencies"].(map[string]any); ok { + for _, raw := range dependencies { + if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) { + modified = true + } + } } return modified } @@ -1064,7 +1217,7 @@ func normalizeOpenAIResponsesImageOnlyModel(reqBody map[string]any) bool { } func normalizeOpenAIModelForUpstream(account *Account, model string) string { - if account == nil || account.Type == AccountTypeOAuth { + if account == nil || account.UsesOpenAICodexProtocol() { return normalizeCodexModel(model) } return strings.TrimSpace(model) @@ -1357,8 +1510,77 @@ func filterCodexInput(input []any, preserveReferences bool) []any { }) } +func normalizeCodexFilterCallID(itemType, id string, preserve bool) string { + if preserve && len(id) <= codexCallIDMaxLength { + return id + } + return normalizeCodexCallIDForItemType(itemType, id) +} + +func codexItemReferenceIDMappings(input []any, preserveCallIDs bool) map[string]string { + mappings := make(map[string]string) + ambiguous := make(map[string]struct{}) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + itemType := strings.TrimSpace(firstNonEmptyString(item["type"])) + if !isCodexToolCallItemType(itemType) { + continue + } + rawCallID := strings.TrimSpace(firstNonEmptyString(item["call_id"])) + if rawCallID == "" { + continue + } + normalized := normalizeCodexFilterCallID(itemType, rawCallID, preserveCallIDs) + if existing, exists := mappings[rawCallID]; exists && existing != normalized { + delete(mappings, rawCallID) + ambiguous[rawCallID] = struct{}{} + continue + } + if _, conflict := ambiguous[rawCallID]; !conflict { + mappings[rawCallID] = normalized + } + } + return mappings +} + +func codexInputItemIDs(input []any) map[string]struct{} { + itemIDs := make(map[string]struct{}) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) == "item_reference" { + continue + } + itemType := strings.TrimSpace(firstNonEmptyString(item["type"])) + id := strings.TrimSpace(firstNonEmptyString(item["id"])) + if id != "" && !shouldStripOpenAIResponsesInputItemID(itemType, id) { + itemIDs[id] = struct{}{} + } + } + return itemIDs +} + +func codexInputCallIDs(input []any) map[string]struct{} { + callIDs := make(map[string]struct{}) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || !isCodexToolCallItemType(strings.TrimSpace(firstNonEmptyString(item["type"]))) { + continue + } + if callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])); callID != "" { + callIDs[callID] = struct{}{} + } + } + return callIDs +} + func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any { filtered := make([]any, 0, len(input)) + referenceIDMappings := codexItemReferenceIDMappings(input, opts.PreserveCallIDs) + inputItemIDs := codexInputItemIDs(input) + inputCallIDs := codexInputCallIDs(input) for _, item := range input { m, ok := item.(map[string]any) if !ok { @@ -1391,7 +1613,7 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an if typ == "reasoning" { newItem := make(map[string]any, len(m)) for key, value := range m { - if key == "id" { + if key == "id" || key == "call_id" { // rs_* id replayed under store=false 404s; strip it. continue } @@ -1408,18 +1630,7 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an // 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id; // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。 fixCallIDPrefix := func(id string) string { - if opts.PreserveCallIDs { - // preserve 模式尽量原样透传客户端 id 以维持 tool_use/tool_result - // 配对,但上游对 call_id 有 64 字符硬上限,超长原样透传必然被 - // 400 拒绝("Invalid 'input[N].call_id': string too long")。 - // 超长时退回确定性压缩:同一逻辑 id 在 function_call 与 - // function_call_output 两侧结果一致,配对不受影响。 - if len(id) <= codexCallIDMaxLength { - return id - } - return compactCodexCallID(id) - } - return normalizeCodexCallID(id) + return normalizeCodexFilterCallID(typ, id, opts.PreserveCallIDs) } if typ == "item_reference" { @@ -1430,8 +1641,18 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an for key, value := range m { newItem[key] = value } - if id, ok := newItem["id"].(string); ok && strings.HasPrefix(id, "call_") { - newItem["id"] = fixCallIDPrefix(id) + if id, ok := newItem["id"].(string); ok && strings.HasPrefix(strings.TrimSpace(id), "call_") { + trimmedID := strings.TrimSpace(id) + _, referencesExistingItem := inputItemIDs[trimmedID] + if !referencesExistingItem { + if normalizedID, mapped := referenceIDMappings[trimmedID]; mapped { + newItem["id"] = normalizedID + } else if _, hasSameTurnCall := inputCallIDs[trimmedID]; !hasSameTurnCall { + // A bare call_* reference is a legacy function-call identifier. + // Normalize it even when its call item lives in an earlier turn. + newItem["id"] = normalizeCodexCallID(trimmedID) + } + } } filtered = append(filtered, newItem) continue diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 9489ab60df..96e7715d5d 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -127,6 +127,28 @@ func TestApplyCodexOAuthTransform_ToolContinuationNormalizesToolReferenceIDsOnly require.Equal(t, "fc_1", second["call_id"]) } +func TestApplyCodexOAuthTransform_NormalizesIsolatedLegacyReferenceAcrossTurns(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.2", + "input": []any{ + map[string]any{"type": "item_reference", "id": "call_previous_turn"}, + map[string]any{"type": "item_reference", "id": "fc_remote_item"}, + map[string]any{"type": "item_reference", "id": "vendor_remote_item"}, + }, + } + + applyCodexOAuthTransform(reqBody, false, false) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 3) + for i, expectedID := range []string{"fc_previous_turn", "fc_remote_item", "vendor_remote_item"} { + item, itemOK := input[i].(map[string]any) + require.True(t, itemOK) + require.Equal(t, expectedID, item["id"]) + } +} + func TestApplyCodexOAuthTransform_BoundsLongCallIDsAndPreservesPairing(t *testing.T) { suffix := strings.Repeat("z", 62) for _, tc := range []struct { @@ -233,7 +255,7 @@ func TestApplyCodexOAuthTransform_CompactsOverlongCallIDsWhenPreserveRequested(t require.Len(t, compacted, codexCallIDMaxLength) require.True(t, strings.HasPrefix(compacted, codexCallIDPrefix)) require.Equal(t, compacted, output["call_id"], "两侧压缩结果必须一致以保持配对") - require.Equal(t, compactCodexCallID(callID), compacted, "压缩必须是确定性的") + require.Equal(t, normalizeCodexCallID(callID), compacted, "压缩必须是确定性的") } func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T) { @@ -253,7 +275,7 @@ func TestApplyCodexOAuthTransform_ToolSearchOutputPreservesCallID(t *testing.T) first, ok := input[0].(map[string]any) require.True(t, ok) require.Equal(t, "tool_search_output", first["type"]) - require.Equal(t, "fc_1", first["call_id"]) + require.Equal(t, "tsc_1", first["call_id"]) } func TestApplyCodexOAuthTransform_CustomAndMCPToolOutputsPreserveCallID(t *testing.T) { @@ -273,13 +295,105 @@ func TestApplyCodexOAuthTransform_CustomAndMCPToolOutputsPreserveCallID(t *testi first, ok := input[0].(map[string]any) require.True(t, ok) - require.Equal(t, "fc_custom", first["call_id"]) + require.Equal(t, "ctc_custom", first["call_id"]) second, ok := input[1].(map[string]any) require.True(t, ok) require.Equal(t, "fc_mcp", second["call_id"]) } +func TestApplyCodexOAuthTransform_NormalizesNativeToolCallPairsByType(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.6-sol", + "input": []any{ + map[string]any{"type": "custom_tool_call", "id": "fc_custom", "call_id": "call_custom", "name": "apply_patch"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "fc_custom", "output": "done"}, + map[string]any{"type": "tool_search_call", "id": "fc_search", "call_id": "call_search"}, + map[string]any{"type": "tool_search_output", "call_id": "fc_search", "output": "result"}, + }, + } + + applyCodexOAuthTransform(reqBody, false, false) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 4) + custom, ok := input[0].(map[string]any) + require.True(t, ok) + customOutput, ok := input[1].(map[string]any) + require.True(t, ok) + search, ok := input[2].(map[string]any) + require.True(t, ok) + searchOutput, ok := input[3].(map[string]any) + require.True(t, ok) + require.NotContains(t, custom, "id", "the invalid replay item id must be removed, not fabricated") + require.Equal(t, "ctc_custom", custom["call_id"]) + require.Equal(t, custom["call_id"], customOutput["call_id"]) + require.NotContains(t, search, "id", "the invalid replay item id must be removed, not fabricated") + require.Equal(t, "tsc_search", search["call_id"]) + require.Equal(t, search["call_id"], searchOutput["call_id"]) +} + +func TestApplyCodexOAuthTransform_PreservesNativeCallIDsWhenRequested(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.6-sol", + "input": []any{ + map[string]any{"type": "custom_tool_call", "id": "ctc_custom", "call_id": "call_custom", "name": "apply_patch"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "done"}, + map[string]any{"type": "tool_search_call", "id": "tsc_search", "call_id": "call_search"}, + map[string]any{"type": "tool_search_output", "call_id": "call_search", "output": "result"}, + }, + } + + applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{PreserveToolCallIDs: true}) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 4) + first, ok := input[0].(map[string]any) + require.True(t, ok) + second, ok := input[1].(map[string]any) + require.True(t, ok) + third, ok := input[2].(map[string]any) + require.True(t, ok) + fourth, ok := input[3].(map[string]any) + require.True(t, ok) + require.Equal(t, "ctc_custom", first["id"]) + require.Equal(t, "call_custom", first["call_id"]) + require.Equal(t, "call_custom", second["call_id"]) + require.Equal(t, "tsc_search", third["id"]) + require.Equal(t, "call_search", third["call_id"]) + require.Equal(t, "call_search", fourth["call_id"]) +} + +func TestApplyCodexOAuthTransform_BoundsEquivalentNativeToolCallIDsWithPairing(t *testing.T) { + suffix := strings.Repeat("x", 70) + reqBody := map[string]any{ + "model": "gpt-5.6-sol", + "input": []any{ + map[string]any{"type": "custom_tool_call", "call_id": "call_" + suffix, "name": "apply_patch"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "fc_" + suffix, "output": "done"}, + }, + } + + applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{PreserveToolCallIDs: true}) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 2) + first, ok := input[0].(map[string]any) + require.True(t, ok) + second, ok := input[1].(map[string]any) + require.True(t, ok) + callID, ok := first["call_id"].(string) + require.True(t, ok) + outputCallID, ok := second["call_id"].(string) + require.True(t, ok) + require.LessOrEqual(t, len(callID), codexCallIDMaxLength) + require.True(t, strings.HasPrefix(callID, "ctc_")) + require.Equal(t, callID, outputCallID) +} + func TestApplyCodexOAuthTransform_ImageAndWebSearchCallsDoNotGainCallID(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.2", @@ -468,7 +582,7 @@ func TestApplyCodexOAuthTransform_PreservesFunctionCallInputName(t *testing.T) { item, ok := input[0].(map[string]any) require.True(t, ok) require.Equal(t, "shell", item["name"]) - require.Equal(t, "fc_1", item["call_id"]) + require.Equal(t, "ctc_1", item["call_id"]) } func TestApplyCodexOAuthTransform_PreservesMCPToolCallIDAndName(t *testing.T) { @@ -1651,11 +1765,14 @@ func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) { func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.4", + "chat_template_kwargs": map[string]any{"enable_thinking": true}, "user": "user_123", "metadata": map[string]any{"trace_id": "abc"}, "prompt_cache_retention": "24h", "safety_identifier": "sid", "stream_options": map[string]any{"include_usage": true}, + "truncation": "auto", + "stop_sequences": []any{"END"}, "input": []any{ map[string]any{"role": "user", "content": "hi"}, }, @@ -1669,6 +1786,46 @@ func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *test } } +func TestApplyCodexOAuthTransform_NormalizesPromptAndCommands(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.5", + "prompt": "hello", + "commands": []any{"unsupported"}, + } + + result := applyCodexOAuthTransform(reqBody, true, false) + require.True(t, result.Modified) + require.Equal(t, []any{ + map[string]any{"type": "message", "role": "user", "content": "hello"}, + }, reqBody["input"]) + require.NotContains(t, reqBody, "prompt") + require.NotContains(t, reqBody, "commands") +} + +func TestNormalizeOpenAIResponsesImageGenerationTools_StripsGPTImage2InputFidelity(t *testing.T) { + reqBody := map[string]any{"tools": []any{ + map[string]any{"type": "image_generation", "model": "gpt-image-2-codex", "input_fidelity": "high"}, + map[string]any{"type": "image_generation", "model": "gpt-image-1.5", "input_fidelity": "high"}, + }} + + require.True(t, normalizeOpenAIResponsesImageGenerationTools(reqBody)) + tools, ok := reqBody["tools"].([]any) + require.True(t, ok) + require.Len(t, tools, 2) + first, ok := tools[0].(map[string]any) + require.True(t, ok) + second, ok := tools[1].(map[string]any) + require.True(t, ok) + require.NotContains(t, first, "input_fidelity") + require.Equal(t, "high", second["input_fidelity"]) +} + +func TestOpenAIRequestBodyImageGenerationToolNeedsNormalization_GPTImage2InputFidelity(t *testing.T) { + body := []byte(`{"tools":[{"type":"image_generation","model":"gpt-image-2-codex","input_fidelity":"high"}]}`) + + require.True(t, openAIRequestBodyImageGenerationToolNeedsNormalization(body)) +} + func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.1", @@ -1770,6 +1927,7 @@ func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) { map[string]any{ "type": "reasoning", "id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344", + "call_id": "remove_non_pair_call_id", "encrypted_content": "gAAAAAB-enc-payload", "summary": []any{}, }, @@ -1792,6 +1950,8 @@ func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) { // PreserveReferences=true (id lookup, not the item, triggers the 404). _, hasID := item["id"] require.False(t, hasID) + _, hasCallID := item["call_id"] + require.False(t, hasCallID) // summary passed through untouched. summary, ok := item["summary"].([]any) require.True(t, ok) diff --git a/backend/internal/service/openai_codex_turn_state.go b/backend/internal/service/openai_codex_turn_state.go new file mode 100644 index 0000000000..d5598c3d30 --- /dev/null +++ b/backend/internal/service/openai_codex_turn_state.go @@ -0,0 +1,165 @@ +package service + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// openAICodexTurnStateHeader 是 Codex 的回合状态头。上游在响应头中铸造该 +// 不透明 blob,客户端在同一回合的后续请求中原样回带(codex-rs 侧从 +// /responses SSE、/responses/compact JSON 与 WS 握手三种响应中捕获,见 +// codex-api/src/sse/responses.rs 与 endpoint/compact.rs)。 +const openAICodexTurnStateHeader = "x-codex-turn-state" + +// turn-state blob 是上游在"出站身份"(含 #5553 指纹收敛改写后的 +// installation/session/thread 标识)下铸造的,同账号回放自洽;跨账号回放 +// (failover 换号后客户端仍回带旧账号的 blob)是代理链独有、真实 Codex +// 永远不会产生的矛盾信号。溯源表记录每个下游会话最近一次铸造该 blob 的 +// 账号,出站守卫据此剥离已知异账号的回带值。 +type openAICodexTurnStateOrigin struct { + accountID int64 + expiresAt time.Time +} + +// openAICodexTurnStateSeed 返回溯源表键:API Key + 客户端原始会话标识。 +// 客户端会话标识取自请求头(与指纹收敛的 thread 派生同源,见 +// extractClientSessionID),确保同一下游会话的记录/守卫两侧使用同一键。 +// 无会话标识时返回空串,表示不做跟踪(保持透传现状)。 +func openAICodexTurnStateSeed(c *gin.Context) string { + if c == nil || c.Request == nil { + return "" + } + sessionID := extractClientSessionID(c.Request.Header) + if sessionID == "" { + return "" + } + return strconv.FormatInt(getAPIKeyIDFromContext(c), 10) + "\x00" + sessionID +} + +// relayOpenAICodexTurnState 将上游响应中的 turn-state 显式写入下游响应头, +// 并记录铸造账号。必须在响应头提交点调用(WriteHeader 之前、且确认本次 +// 上游响应就是将要写回客户端的响应之后)。上游无该头时主动清除 writer 上 +// 可能残留的上一 failover attempt 的值——否则换号后旧账号的 blob 会粘到 +// 新账号的响应上,这正是本文件要防止的跨账号矛盾。 +func (s *OpenAIGatewayService) relayOpenAICodexTurnState(c *gin.Context, account *Account, upstream http.Header) { + if c == nil || c.Writer == nil { + return + } + canonical := http.CanonicalHeaderKey(openAICodexTurnStateHeader) + state := extractOpenAICodexTurnState(upstream) + if state == "" { + c.Writer.Header().Del(canonical) + return + } + c.Writer.Header().Set(canonical, state) + s.noteOpenAICodexTurnStateProvenance(c, account) +} + +// stageOpenAICodexTurnState 将上游 turn-state 暂存到延迟提交的响应头集合 +// (首输出守卫路径先缓存头、见到首个输出事件才提交)。此处**不**记录铸造 +// 账号:该 attempt 仍可能在首输出超时后 failover,暂存头会被整体丢弃, +// 客户端从未收到该 blob。溯源必须在真正提交时记录,见 +// noteStagedOpenAICodexTurnStateCommitted。 +func stageOpenAICodexTurnState(dst *http.Header, upstream http.Header) { + if dst == nil { + return + } + canonical := http.CanonicalHeaderKey(openAICodexTurnStateHeader) + state := extractOpenAICodexTurnState(upstream) + if state == "" { + if *dst != nil { + dst.Del(canonical) + } + return + } + if *dst == nil { + *dst = http.Header{} + } + dst.Set(canonical, state) +} + +// noteStagedOpenAICodexTurnStateCommitted 在暂存响应头真正写入下游时记录 +// 铸造账号——只有此刻客户端才确定收到了该 blob,溯源表才与客户端持有的 +// 值一致(否则被 failover 丢弃的 attempt 会污染溯源,导致后续误剥离)。 +func (s *OpenAIGatewayService) noteStagedOpenAICodexTurnStateCommitted(c *gin.Context, account *Account, staged http.Header) { + if staged == nil || strings.TrimSpace(staged.Get(openAICodexTurnStateHeader)) == "" { + return + } + s.noteOpenAICodexTurnStateProvenance(c, account) +} + +func extractOpenAICodexTurnState(upstream http.Header) string { + if upstream == nil { + return "" + } + return strings.TrimSpace(upstream.Get(openAICodexTurnStateHeader)) +} + +// noteOpenAICodexTurnStateProvenance 记录(下游会话 → 铸造账号)。 +func (s *OpenAIGatewayService) noteOpenAICodexTurnStateProvenance(c *gin.Context, account *Account) { + if s == nil || account == nil || account.ID <= 0 { + return + } + seed := openAICodexTurnStateSeed(c) + if seed == "" { + return + } + s.openaiCodexTurnStateOrigins.Store(seed, openAICodexTurnStateOrigin{ + accountID: account.ID, + expiresAt: time.Now().Add(s.openAIWSSessionStickyTTL()), + }) + s.sweepOpenAICodexTurnStateOrigins() +} + +// guardOpenAICodexTurnStateEcho 出站守卫:客户端回带的 turn-state 若已知由 +// 其他账号铸造则剥离,同账号或无溯源记录时保持原样。只剥离、不注入—— +// /responses 路径的客户端是真实 Codex,会按自身回合语义自行回带;服务端 +// 注入是 Claude 兼容桥(无法回带的客户端)的专属行为。 +func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, account *Account, h http.Header) { + if s == nil || h == nil || account == nil { + return + } + if strings.TrimSpace(h.Get(openAICodexTurnStateHeader)) == "" { + return + } + seed := openAICodexTurnStateSeed(c) + if seed == "" { + return + } + raw, ok := s.openaiCodexTurnStateOrigins.Load(seed) + if !ok { + return + } + origin, ok := raw.(openAICodexTurnStateOrigin) + if !ok { + s.openaiCodexTurnStateOrigins.Delete(seed) + return + } + if !origin.expiresAt.IsZero() && time.Now().After(origin.expiresAt) { + s.openaiCodexTurnStateOrigins.Delete(seed) + return + } + if origin.accountID != account.ID { + h.Del(openAICodexTurnStateHeader) + } +} + +// sweepOpenAICodexTurnStateOrigins 机会式清扫过期溯源记录:每 256 次写入 +// 全量遍历一轮,防止仅靠读侧惰性删除导致的慢泄漏(会话键无上界)。 +func (s *OpenAIGatewayService) sweepOpenAICodexTurnStateOrigins() { + if s.openaiCodexTurnStateWrites.Add(1)%256 != 0 { + return + } + now := time.Now() + s.openaiCodexTurnStateOrigins.Range(func(key, value any) bool { + origin, ok := value.(openAICodexTurnStateOrigin) + if !ok || (!origin.expiresAt.IsZero() && now.After(origin.expiresAt)) { + s.openaiCodexTurnStateOrigins.Delete(key) + } + return true + }) +} diff --git a/backend/internal/service/openai_codex_turn_state_test.go b/backend/internal/service/openai_codex_turn_state_test.go new file mode 100644 index 0000000000..141f089f52 --- /dev/null +++ b/backend/internal/service/openai_codex_turn_state_test.go @@ -0,0 +1,396 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newTurnStateTestContext(t *testing.T, apiKeyID int64, sessionID string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + if sessionID != "" { + c.Request.Header.Set("session_id", sessionID) + } + if apiKeyID > 0 { + c.Set("api_key", &APIKey{ID: apiKeyID}) + } + return c, rec +} + +func TestOpenAICodexTurnStateSeed(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-1") + require.Equal(t, "7\x00sess-1", openAICodexTurnStateSeed(c)) + + // 连字符形式优先(Codex CLI 标准头) + c.Request.Header.Set("session-id", "sess-hyphen") + require.Equal(t, "7\x00sess-hyphen", openAICodexTurnStateSeed(c)) + + // 无会话标识 → 不跟踪 + cNoSession, _ := newTurnStateTestContext(t, 7, "") + require.Empty(t, openAICodexTurnStateSeed(cNoSession)) + + require.Empty(t, openAICodexTurnStateSeed(nil)) +} + +func TestRelayOpenAICodexTurnState_SetsHeaderAndRecordsProvenance(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 42} + c, _ := newTurnStateTestContext(t, 7, "sess-relay") + + upstream := http.Header{} + upstream.Set("x-codex-turn-state", "blob-A") + svc.relayOpenAICodexTurnState(c, account, upstream) + + require.Equal(t, "blob-A", c.Writer.Header().Get("X-Codex-Turn-State")) + + raw, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-relay") + require.True(t, ok) + origin, ok := raw.(openAICodexTurnStateOrigin) + require.True(t, ok) + require.Equal(t, int64(42), origin.accountID) + require.True(t, origin.expiresAt.After(time.Now())) +} + +func TestRelayOpenAICodexTurnState_ClearsStaleValueWhenUpstreamAbsent(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "sess-stale") + // 模拟上一 failover attempt 残留的值 + c.Writer.Header().Set("X-Codex-Turn-State", "blob-old") + + svc.relayOpenAICodexTurnState(c, &Account{ID: 43}, http.Header{}) + + require.Empty(t, c.Writer.Header().Get("X-Codex-Turn-State")) + _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-stale") + require.False(t, ok) +} + +func TestStageOpenAICodexTurnState_StagedHeaders(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 9, "sess-staged") + + // nil 集合 + 上游有值 → 创建集合并写入,但此刻还不记录溯源 + var staged http.Header + upstream := http.Header{} + upstream.Set("x-codex-turn-state", "blob-B") + stageOpenAICodexTurnState(&staged, upstream) + require.NotNil(t, staged) + require.Equal(t, "blob-B", staged.Get("X-Codex-Turn-State")) + _, noted := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged") + require.False(t, noted, "暂存阶段不得记录溯源:该 attempt 仍可能 failover 丢弃") + + // 真正提交时才记录 + svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 44}, staged) + raw, ok := svc.openaiCodexTurnStateOrigins.Load("9\x00sess-staged") + require.True(t, ok) + origin, ok := raw.(openAICodexTurnStateOrigin) + require.True(t, ok) + require.Equal(t, int64(44), origin.accountID) + + // 上游无值 → 清除已暂存的值;nil 集合保持 nil + stageOpenAICodexTurnState(&staged, http.Header{}) + require.Empty(t, staged.Get("X-Codex-Turn-State")) + var nilStaged http.Header + stageOpenAICodexTurnState(&nilStaged, http.Header{}) + require.Nil(t, nilStaged) +} + +// 首输出超时导致 attempt 被丢弃时,溯源不得被该 attempt 污染——否则后续 +// 请求会把客户端持有的合法 blob 误判成跨账号回带而剥离。 +func TestStagedTurnState_AbandonedAttemptDoesNotPoisonProvenance(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 11, "sess-abandoned") + + // 账号 A 的 attempt 暂存了 blob,但从未提交(首输出超时 → failover) + var staged http.Header + upstreamA := http.Header{} + upstreamA.Set("x-codex-turn-state", "blob-A") + stageOpenAICodexTurnState(&staged, upstreamA) + + // 账号 B 接手并真正提交 + svc.relayOpenAICodexTurnState(c, &Account{ID: 52}, upstreamA) + + // 客户端回带的 blob 来自 B,出站到 B 时不得被剥离 + h := http.Header{} + h.Set("x-codex-turn-state", "blob-A") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 52}, h) + require.Equal(t, "blob-A", h.Get("x-codex-turn-state")) + + raw, ok := svc.openaiCodexTurnStateOrigins.Load("11\x00sess-abandoned") + require.True(t, ok) + origin, ok := raw.(openAICodexTurnStateOrigin) + require.True(t, ok) + require.Equal(t, int64(52), origin.accountID) +} + +func TestNoteStagedOpenAICodexTurnStateCommitted_NoopWithoutState(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 12, "sess-nostate") + + svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 60}, nil) + svc.noteStagedOpenAICodexTurnStateCommitted(c, &Account{ID: 60}, http.Header{"X-Request-Id": []string{"rid"}}) + + _, ok := svc.openaiCodexTurnStateOrigins.Load("12\x00sess-nostate") + require.False(t, ok) +} + +func TestGuardOpenAICodexTurnStateEcho(t *testing.T) { + newOutbound := func(state string) http.Header { + h := http.Header{} + if state != "" { + h.Set("x-codex-turn-state", state) + } + return h + } + + t.Run("same_account_keeps_echo", func(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "sess-g1") + upstream := http.Header{} + upstream.Set("x-codex-turn-state", "blob-A") + svc.relayOpenAICodexTurnState(c, &Account{ID: 42}, upstream) + + h := newOutbound("blob-A") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 42}, h) + require.Equal(t, "blob-A", h.Get("x-codex-turn-state")) + }) + + t.Run("foreign_account_strips_echo", func(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "sess-g2") + upstream := http.Header{} + upstream.Set("x-codex-turn-state", "blob-A") + svc.relayOpenAICodexTurnState(c, &Account{ID: 42}, upstream) + + // failover 换到账号 43:blob 由 42 铸造,必须剥离 + h := newOutbound("blob-A") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h) + require.Empty(t, h.Get("x-codex-turn-state")) + }) + + t.Run("no_provenance_passthrough", func(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "sess-g3") + h := newOutbound("blob-unknown") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h) + require.Equal(t, "blob-unknown", h.Get("x-codex-turn-state")) + }) + + t.Run("expired_provenance_passthrough_and_pruned", func(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "sess-g4") + svc.openaiCodexTurnStateOrigins.Store("7\x00sess-g4", openAICodexTurnStateOrigin{ + accountID: 42, + expiresAt: time.Now().Add(-time.Minute), + }) + h := newOutbound("blob-A") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h) + require.Equal(t, "blob-A", h.Get("x-codex-turn-state")) + _, ok := svc.openaiCodexTurnStateOrigins.Load("7\x00sess-g4") + require.False(t, ok) + }) + + t.Run("no_session_seed_noop", func(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "") + h := newOutbound("blob-A") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h) + require.Equal(t, "blob-A", h.Get("x-codex-turn-state")) + }) + + t.Run("no_echo_noop", func(t *testing.T) { + svc := &OpenAIGatewayService{} + c, _ := newTurnStateTestContext(t, 7, "sess-g5") + h := newOutbound("") + svc.guardOpenAICodexTurnStateEcho(c, &Account{ID: 43}, h) + require.Empty(t, h.Get("x-codex-turn-state")) + }) +} + +func TestSweepOpenAICodexTurnStateOrigins_PrunesExpiredEntries(t *testing.T) { + svc := &OpenAIGatewayService{} + svc.openaiCodexTurnStateOrigins.Store("expired", openAICodexTurnStateOrigin{ + accountID: 1, + expiresAt: time.Now().Add(-time.Minute), + }) + svc.openaiCodexTurnStateOrigins.Store("alive", openAICodexTurnStateOrigin{ + accountID: 2, + expiresAt: time.Now().Add(time.Hour), + }) + + // 计数器推进到触发清扫的边界 + svc.openaiCodexTurnStateWrites.Store(255) + svc.sweepOpenAICodexTurnStateOrigins() + + _, expiredOK := svc.openaiCodexTurnStateOrigins.Load("expired") + require.False(t, expiredOK) + _, aliveOK := svc.openaiCodexTurnStateOrigins.Load("alive") + require.True(t, aliveOK) +} + +func TestWriteOpenAIPassthroughResponseHeaders_RelaysAndClearsTurnState(t *testing.T) { + // filter=nil 走 content-type 兜底分支;turn-state 强制放行不依赖 filter。 + dst := http.Header{} + src := http.Header{} + src.Set("X-Codex-Turn-State", "blob-P") + writeOpenAIPassthroughResponseHeaders(dst, src, nil) + require.Equal(t, "blob-P", dst.Get("X-Codex-Turn-State")) + + // 上游缺失时清除残留(failover 换号防串扰) + writeOpenAIPassthroughResponseHeaders(dst, http.Header{"Content-Type": []string{"application/json"}}, nil) + require.Empty(t, dst.Get("X-Codex-Turn-State")) +} + +func TestWriteOpenAIPassthroughResponseHeaders_RelaysReasoningIncluded(t *testing.T) { + dst := http.Header{} + src := http.Header{} + src.Set("X-Reasoning-Included", "1") + + writeOpenAIPassthroughResponseHeaders( + dst, + src, + responseheaders.CompileHeaderFilter(config.ResponseHeaderConfig{}), + ) + require.Equal(t, "1", dst.Get("X-Reasoning-Included")) +} + +func TestEnsureOpenAIRemoteCompactionV2BetaFeature(t *testing.T) { + t.Run("absent_sets_feature", func(t *testing.T) { + h := http.Header{} + ensureOpenAIRemoteCompactionV2BetaFeature(h) + require.Equal(t, "remote_compaction_v2", h.Get("x-codex-beta-features")) + }) + + t.Run("present_unchanged", func(t *testing.T) { + h := http.Header{} + h.Set("x-codex-beta-features", "responses_websockets_v2, remote_compaction_v2") + ensureOpenAIRemoteCompactionV2BetaFeature(h) + require.Equal(t, "responses_websockets_v2, remote_compaction_v2", h.Get("x-codex-beta-features")) + }) + + t.Run("other_tokens_merged", func(t *testing.T) { + h := http.Header{} + h.Set("x-codex-beta-features", "responses_websockets_v2") + ensureOpenAIRemoteCompactionV2BetaFeature(h) + require.Equal(t, "responses_websockets_v2,remote_compaction_v2", h.Get("x-codex-beta-features")) + }) + + t.Run("multi_line_values_merged_single_line", func(t *testing.T) { + h := http.Header{} + h.Add("x-codex-beta-features", "feature_a") + h.Add("x-codex-beta-features", "feature_b") + ensureOpenAIRemoteCompactionV2BetaFeature(h) + require.Equal(t, []string{"feature_a,feature_b,remote_compaction_v2"}, h.Values("x-codex-beta-features")) + }) +} + +// 对齐真实 Codex:该头是会话级常量,挂在 OAuth 的每个请求上,而不是只在 +// 压缩回合出现(codex-rs build_model_client_beta_features_header)。 +func TestApplyOpenAICodexBetaFeatures(t *testing.T) { + oauthAccount := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKeyAccount := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + t.Run("oauth_plain_request_gets_default_codex_shape", func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-beta") + h := http.Header{} + applyOpenAICodexBetaFeatures(c, oauthAccount, h) + require.Equal(t, "remote_compaction_v2", h.Get("x-codex-beta-features"), + "OAuth 的普通请求也必须带会话级 beta 头") + }) + + t.Run("client_declared_header_preserved", func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-beta") + h := http.Header{} + h.Set("x-codex-beta-features", "some_other_feature") + applyOpenAICodexBetaFeatures(c, oauthAccount, h) + require.Equal(t, "some_other_feature", h.Get("x-codex-beta-features"), + "客户端显式声明的能力集不得被网关改写(非空即视为用户已关闭 v2)") + }) + + t.Run("native_v2_forces_feature_even_when_client_trimmed_it", func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-beta") + MarkOpenAINativeCompactionV2(c) + h := http.Header{} + h.Set("x-codex-beta-features", "some_other_feature") + applyOpenAICodexBetaFeatures(c, oauthAccount, h) + require.Contains(t, h.Get("x-codex-beta-features"), "remote_compaction_v2", + "body 带 compaction_trigger 是实锤,必须确保 v2 在列") + require.Contains(t, h.Get("x-codex-beta-features"), "some_other_feature") + }) + + t.Run("native_v2_applies_to_non_oauth_too", func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-beta") + MarkOpenAINativeCompactionV2(c) + h := http.Header{} + applyOpenAICodexBetaFeatures(c, apiKeyAccount, h) + require.Equal(t, "remote_compaction_v2", h.Get("x-codex-beta-features")) + }) + + t.Run("non_oauth_plain_request_untouched", func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-beta") + h := http.Header{} + applyOpenAICodexBetaFeatures(c, apiKeyAccount, h) + require.Empty(t, h.Get("x-codex-beta-features"), + "非 Codex 后端不做会话级注入") + }) + + t.Run("nil_account_plain_request_untouched", func(t *testing.T) { + c, _ := newTurnStateTestContext(t, 7, "sess-beta") + h := http.Header{} + applyOpenAICodexBetaFeatures(c, nil, h) + require.Empty(t, h.Get("x-codex-beta-features")) + }) +} + +// WS 握手与 HTTP 出站必须给出同一份会话级 beta 头:真实 Codex 的 +// build_websocket_headers 复用 build_responses_headers(client.rs), +// 两侧不一致还会让预热连接与实际请求落进不同的连接池兼容分桶。 +func TestBuildOpenAIWSHeaders_CarriesSessionBetaFeatures(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{} + decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2} + + build := func(t *testing.T, account *Account, clientBeta string) http.Header { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + if clientBeta != "" { + c.Request.Header.Set("x-codex-beta-features", clientBeta) + } + headers, _, err := svc.buildOpenAIWSHeaders( + context.Background(), c, account, "test-token", decision, + true, "", "", "", "gpt-5.6-codex", "", + ) + require.NoError(t, err) + return headers + } + + oauthAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "test-account"}, + } + + headers := build(t, oauthAccount, "") + require.Equal(t, "remote_compaction_v2", headers.Get("x-codex-beta-features"), + "WS 握手也必须带会话级 beta 头") + + declared := build(t, oauthAccount, "some_other_feature") + require.Equal(t, []string{"some_other_feature"}, declared.Values("x-codex-beta-features"), + "客户端已声明时原样保留") + + apiKeyHeaders := build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "") + require.Empty(t, apiKeyHeaders.Get("x-codex-beta-features"), + "非 Codex 后端不注入") +} diff --git a/backend/internal/service/openai_codex_version_consistency_test.go b/backend/internal/service/openai_codex_version_consistency_test.go index aede2ae432..fe95994237 100644 --- a/backend/internal/service/openai_codex_version_consistency_test.go +++ b/backend/internal/service/openai_codex_version_consistency_test.go @@ -11,9 +11,6 @@ import ( ) func TestCodexVersionConstants_Consistency(t *testing.T) { - require.Equal(t, codexCLIVersion, openAICodexProbeVersion, - "codexCLIVersion and openAICodexProbeVersion must stay in sync") - require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion), "codexCLIUserAgent must embed codexCLIVersion") diff --git a/backend/internal/service/openai_compact_body_signal.go b/backend/internal/service/openai_compact_body_signal.go index ce561b0c5a..c2aaf870f9 100644 --- a/backend/internal/service/openai_compact_body_signal.go +++ b/backend/internal/service/openai_compact_body_signal.go @@ -1,11 +1,160 @@ package service -import "github.com/tidwall/gjson" +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// openAINativeCompactionV2Key 标记本请求是原生 remote compaction v2 +// (裸 /responses + stream:true + compaction_trigger),由 handler 在判定后 +// 写入,供上游请求构造时补注协商头。 +const openAINativeCompactionV2Key = "openai_native_compaction_v2" + +const openAIRemoteCompactionV2Feature = "remote_compaction_v2" + +// MarkOpenAINativeCompactionV2 由 handler 在识别出原生 v2 压缩请求时调用。 +func MarkOpenAINativeCompactionV2(c *gin.Context) { + if c != nil { + c.Set(openAINativeCompactionV2Key, true) + } +} + +// NormalizeCompactionTriggerInputOrder keeps a single compaction trigger as +// the final Responses input item, as required by the upstream v2 wire format. +func NormalizeCompactionTriggerInputOrder(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + var payload map[string]any + if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil { + return body, false, err + } + input, ok := payload["input"].([]any) + if !ok || len(input) == 0 { + return body, false, nil + } + triggerCount := 0 + normalized := make([]any, 0, len(input)) + for _, raw := range input { + item, itemOK := raw.(map[string]any) + if itemOK && item["type"] == "compaction_trigger" { + triggerCount++ + continue + } + normalized = append(normalized, raw) + } + if triggerCount == 0 { + return body, false, nil + } + if triggerCount == 1 { + if last, ok := input[len(input)-1].(map[string]any); ok && last["type"] == "compaction_trigger" { + return body, false, nil + } + } + normalized = append(normalized, map[string]any{"type": "compaction_trigger"}) + payload["input"] = normalized + encoded, err := marshalOpenAIUpstreamJSON(payload) + if err != nil { + return body, false, err + } + return encoded, true, nil +} + +func isOpenAINativeCompactionV2(c *gin.Context) bool { + if c == nil { + return false + } + return c.GetBool(openAINativeCompactionV2Key) +} + +// ensureOpenAIRemoteCompactionV2BetaFeature 确保出站 x-codex-beta-features +// 头包含 remote_compaction_v2。真实 Codex 发送 compaction_trigger 时总会同时 +// 携带该协商头(codex-rs build_model_client_beta_features_header 对该 feature +// 特判 advertise);上游或下游网关链剥掉它后,请求会在依赖该头做门控的 +// 环节被降级(#5586)。这里在原生 v2 请求出站前补齐,使线型与真实 Codex +// 一致。已存在时保持原样,不重复追加。 +func ensureOpenAIRemoteCompactionV2BetaFeature(h http.Header) { + if h == nil { + return + } + tokens := make([]string, 0, 4) + for _, value := range h.Values("x-codex-beta-features") { + for _, token := range strings.Split(value, ",") { + token = strings.TrimSpace(token) + if token == "" { + continue + } + if token == openAIRemoteCompactionV2Feature { + return + } + tokens = append(tokens, token) + } + } + tokens = append(tokens, openAIRemoteCompactionV2Feature) + h.Set("x-codex-beta-features", strings.Join(tokens, ",")) +} + +// hasOpenAICodexBetaFeaturesHeader 报告出站头里是否已存在非空的 +// x-codex-beta-features(即客户端自己声明过能力集)。 +func hasOpenAICodexBetaFeaturesHeader(h http.Header) bool { + if h == nil { + return false + } + for _, value := range h.Values("x-codex-beta-features") { + if strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +// applyOpenAICodexBetaFeatures 按真实 Codex 的会话级行为补注 +// x-codex-beta-features。 +// +// codex 侧规则(codex-rs:session/mod.rs build_model_client_beta_features_header +// 组装、client.rs build_responses_headers 附加):该头是**会话级常量**,挂在 +// /responses、WS 握手、/responses/compact 三处的**每一个**请求上,而不是只挂 +// 压缩回合。其内容是"已启用且需要 advertise 的 feature 列表",RemoteCompactionV2 +// 被特判 advertise;实测默认安装下没有任何 Experimental 特性默认开启, +// 因此默认 Codex 的头值恰好就是单个 "remote_compaction_v2"。 +// +// 网关据此对齐: +// - 原生 v2 压缩回合(body 带 compaction_trigger 实锤):无论账号类型都确保 +// v2 在列,覆盖中间网关裁剪 token 的情形(#5586); +// - ChatGPT codex 上游(OAuth)的其余请求:客户端**未**声明该头时补成默认 +// Codex 形态,消除"仅压缩回合才带该头"这种真实 Codex 不会产生的模式; +// - 客户端已声明该头:原样保留。非空但不含 v2 表示用户显式关闭了该特性, +// 网关不得替其改写能力声明; +// - 非 OAuth 上游(API Key/第三方兼容网关):不做会话级注入,只保留压缩回合 +// 的那一条,避免向非 Codex 后端撒 Codex 专属头。 +// +// 已知无解的歧义:用户关掉 v2 且无其他特性时,真实 Codex 同样不发该头,与"老 +// 客户端"在线型上不可区分,此时按默认形态补注。该用户的 legacy 压缩端点本就 +// 已被上游下线(404),不存在可回退的正确行为。 +func applyOpenAICodexBetaFeatures(c *gin.Context, account *Account, h http.Header) { + if h == nil { + return + } + if isOpenAINativeCompactionV2(c) { + ensureOpenAIRemoteCompactionV2BetaFeature(h) + return + } + if account == nil || !account.IsOpenAIOAuthLike() { + return + } + if hasOpenAICodexBetaFeaturesHeader(h) { + return + } + h.Set("x-codex-beta-features", openAIRemoteCompactionV2Feature) +} // HasCompactionTriggerInInput detects an input item with // type="compaction_trigger". The handler combines this body signal with the -// request path, stream flag, and Codex beta feature header to distinguish the -// native remote compaction v2 wire from the legacy /responses/compact bridge. +// request path and stream flag to distinguish the native remote compaction v2 +// wire from the legacy /responses/compact bridge. func HasCompactionTriggerInInput(body []byte) bool { if len(body) == 0 { return false diff --git a/backend/internal/service/openai_compact_body_signal_test.go b/backend/internal/service/openai_compact_body_signal_test.go index 8e05f4f98b..16587df0fd 100644 --- a/backend/internal/service/openai_compact_body_signal_test.go +++ b/backend/internal/service/openai_compact_body_signal_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestHasCompactionTriggerInInput_DetectsCompactSignal(t *testing.T) { @@ -54,3 +55,54 @@ func TestHasCompactionTriggerInInput_CompactTriggerOnly(t *testing.T) { body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`) require.True(t, HasCompactionTriggerInInput(body)) } + +func TestNormalizeCompactionTriggerInputOrder_MovesAndCollapsesTriggers(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"},{"type":"message","role":"user","content":"tail"},{"type":"compaction_trigger"}]}`) + normalized, changed, err := NormalizeCompactionTriggerInputOrder(body) + require.NoError(t, err) + require.True(t, changed) + items := gjson.GetBytes(normalized, "input").Array() + require.Len(t, items, 2) + require.Equal(t, "message", items[0].Get("type").String()) + require.Equal(t, "compaction_trigger", items[1].Get("type").String()) +} + +func TestNormalizeCompactionTriggerInputOrder_AlreadyFinalPreservesBytes(t *testing.T) { + body := []byte(`{"input":[{"type":"message"},{"type":"compaction_trigger"}]}`) + normalized, changed, err := NormalizeCompactionTriggerInputOrder(body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(normalized)) +} + +func TestNormalizeCompactionTriggerInputOrder_PreservesHistoryAndLargeNumbers(t *testing.T) { + body := []byte(`{"input":[{"type":"compaction_trigger"},{"type":"message","id":"msg_1","content":"visible"},{"type":"function_call_output","call_id":"call_1","output":"result","sequence":9007199254740993}]}`) + + normalized, changed, err := NormalizeCompactionTriggerInputOrder(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(normalized, "input.1.type").String()) + require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.1.sequence").Raw) + require.Equal(t, "compaction_trigger", gjson.GetBytes(normalized, "input.2.type").String()) + + second, changedAgain, err := NormalizeCompactionTriggerInputOrder(normalized) + require.NoError(t, err) + require.False(t, changedAgain) + require.Equal(t, normalized, second) +} + +func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *testing.T) { + body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false) + require.NoError(t, err) + require.True(t, changed) + items := gjson.GetBytes(normalized, "input").Array() + require.Len(t, items, 4) + require.Equal(t, "function_call", items[0].Get("type").String()) + require.Equal(t, "function_call_output", items[1].Get("type").String()) + require.Equal(t, "message", items[2].Get("type").String()) + require.Equal(t, "compaction_trigger", items[3].Get("type").String()) +} diff --git a/backend/internal/service/openai_compact_fallback.go b/backend/internal/service/openai_compact_fallback.go new file mode 100644 index 0000000000..389a4e8beb --- /dev/null +++ b/backend/internal/service/openai_compact_fallback.go @@ -0,0 +1,297 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +type openAICompactFallbackSignal struct { + payload []byte + message string +} + +func (e *openAICompactFallbackSignal) Error() string { + if e == nil || strings.TrimSpace(e.message) == "" { + return "upstream compact request failed" + } + return e.message +} + +func asOpenAICompactFallbackSignal(err error) (*openAICompactFallbackSignal, bool) { + var signal *openAICompactFallbackSignal + return signal, errors.As(err, &signal) && signal != nil +} + +func isExplicitOpenAICompactContext(c *gin.Context) bool { + return isOpenAIResponsesCompactPath(c) || isOpenAINativeCompactionV2(c) +} + +func newOpenAICompactFallbackSignal(c *gin.Context, payload []byte, message string) error { + if !isExplicitOpenAICompactContext(c) || + !isOpenAICompactModelFailure(http.StatusBadRequest, message, payload) { + return nil + } + return &openAICompactFallbackSignal{ + payload: append([]byte(nil), payload...), + message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)), + } +} + +func isExplicitOpenAICompactRequest(c *gin.Context, body []byte) bool { + return isOpenAIResponsesCompactPath(c) || HasCompactionTriggerInInput(body) +} + +// resolveOpenAICompactFallbackModel prefers the account's compact-only rule +// for the client-visible model. The process-wide fallback is used only when +// that account has no matching compact rule. +func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Account, requestedModel string) string { + requestedModel = strings.TrimSpace(requestedModel) + if account != nil { + if mapped, matched := account.ResolveCompactMappedModel(requestedModel); matched { + if mapped = strings.TrimSpace(mapped); mapped != "" { + return mapped + } + } + } + if s == nil || s.cfg == nil { + return "" + } + fallback := strings.TrimSpace(s.cfg.Gateway.OpenAICompactModel) + if fallback == "" { + return "" + } + return strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false)) +} + +func isOpenAICompactModelFailure(statusCode int, upstreamMsg string, upstreamBody []byte) bool { + if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { + return true + } + if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound { + return false + } + + values := []string{ + extractUpstreamErrorCode(upstreamBody), + upstreamMsg, + gjson.GetBytes(upstreamBody, "error.type").String(), + gjson.GetBytes(upstreamBody, "response.error.code").String(), + gjson.GetBytes(upstreamBody, "response.error.type").String(), + } + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "model_not_found", "model_not_available", "unsupported_model", "invalid_model": + return true + } + if isExplicitOpenAIModelAvailabilityMessage(value) { + return true + } + } + // Some compact providers return only a failed response shell. It is safe to + // retry that shape for an explicit compact request, but a populated error is + // left untouched so business and policy failures keep their original wire. + if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "response.status").String()), "failed") || + strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "status").String()), "failed") { + for _, path := range []string{ + "error.message", "error.code", "error.type", + "response.error.message", "response.error.code", "response.error.type", + } { + if strings.TrimSpace(gjson.GetBytes(upstreamBody, path).String()) != "" { + return false + } + } + return strings.TrimSpace(upstreamMsg) == "" + } + return false +} + +func isExplicitOpenAIModelAvailabilityMessage(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return false + } + for _, phrase := range []string{ + "model not found", + "model does not exist", + "model is unavailable", + "model is not available", + "model is unsupported", + "model is not supported", + "unsupported model", + } { + if strings.Contains(value, phrase) { + return true + } + } + // OpenAI commonly identifies the missing model between the word "model" + // and the terminal availability phrase, for example: "The model `x` does + // not exist". Requiring the message to start with the model subject avoids + // treating unrelated feature errors such as "model output is not supported" + // as a signal to change models. + if strings.HasPrefix(value, "the model ") || strings.HasPrefix(value, "model ") { + return strings.Contains(value, " does not exist") || + strings.Contains(value, " was not found") || + strings.Contains(value, " is unavailable") || + strings.Contains(value, " is not available") + } + return false +} + +func openAICompactFallbackErrorResponse(resp *http.Response, signal *openAICompactFallbackSignal) (*http.Response, []byte) { + headers := make(http.Header) + if resp != nil { + headers = resp.Header.Clone() + } + if headers.Get("Content-Type") == "" { + headers.Set("Content-Type", "application/json") + } + payload := normalizeOpenAICompactFallbackHTTPErrorPayload(signal) + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: headers, + Body: io.NopCloser(bytes.NewReader(payload)), + }, payload +} + +func normalizeOpenAICompactFallbackHTTPErrorPayload(signal *openAICompactFallbackSignal) []byte { + if signal == nil { + return nil + } + payload := append([]byte(nil), signal.payload...) + var terminal struct { + Error json.RawMessage `json:"error"` + Response struct { + Error json.RawMessage `json:"error"` + } `json:"response"` + } + if json.Unmarshal(payload, &terminal) != nil || len(bytes.TrimSpace(terminal.Response.Error)) == 0 || + bytes.Equal(bytes.TrimSpace(terminal.Response.Error), []byte("null")) { + return payload + } + // Standard HTTP error handlers consume error.message/type/code. A streamed + // response.failed terminal nests the same object under response.error, so + // normalize only that envelope at the stream-to-HTTP boundary. + normalized, err := json.Marshal(struct { + Error json.RawMessage `json:"error"` + }{Error: terminal.Response.Error}) + if err != nil { + return payload + } + return normalized +} + +func (s *OpenAIGatewayService) appendOpenAICompactFallbackRetryOps( + c *gin.Context, + account *Account, + resp *http.Response, + payload []byte, + message string, + passthrough bool, +) { + if account == nil { + return + } + statusCode := http.StatusBadRequest + requestID := "" + if resp != nil { + statusCode = resp.StatusCode + requestID = resp.Header.Get("x-request-id") + } + detail := "" + if s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + detail = truncateString(string(payload), maxBytes) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: statusCode, + UpstreamRequestID: requestID, + Passthrough: passthrough, + Kind: "retry", + Reason: "compact_model_fallback", + Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)), + Detail: detail, + UpstreamResponseBody: detail, + }) +} + +// prepareOpenAICompactFallbackRetry returns a body for one safe, same-account +// retry. Callers invoke it only before any downstream response has been +// written; it changes the model and deliberately leaves path, trigger, and +// native-v2 context state untouched. +func (s *OpenAIGatewayService) prepareOpenAICompactFallbackRetry( + c *gin.Context, + account *Account, + requestedModel string, + currentBody []byte, + statusCode int, + upstreamMsg string, + upstreamBody []byte, + alreadyRetried bool, +) ([]byte, string, bool) { + if alreadyRetried || !isExplicitOpenAICompactRequest(c, currentBody) || + !isOpenAICompactModelFailure(statusCode, upstreamMsg, upstreamBody) { + return currentBody, "", false + } + fallbackModel := s.resolveOpenAICompactFallbackModel(account, requestedModel) + currentModel := strings.TrimSpace(gjson.GetBytes(currentBody, "model").String()) + if fallbackModel == "" || strings.EqualFold(fallbackModel, currentModel) { + return currentBody, "", false + } + retryBody := ReplaceModelInBody(currentBody, fallbackModel) + if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(retryBody, "model").String()), currentModel) { + return currentBody, "", false + } + return retryBody, fallbackModel, true +} + +func (s *OpenAIGatewayService) applyOpenAIPassthroughCompactFallbackFromSignal( + c *gin.Context, + account *Account, + requestedModel string, + body []byte, + err error, + alreadyRetried bool, + resp *http.Response, +) ([]byte, string, bool) { + signal, ok := asOpenAICompactFallbackSignal(err) + if !ok { + return body, "", false + } + retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry( + c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, alreadyRetried, + ) + if !retry { + return body, "", false + } + s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, true) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + accountName := "" + if account != nil { + accountName = account.Name + } + SetOpsUpstreamModel(c, fallbackModel) + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)", + accountName, fromModel, fallbackModel, extractUpstreamErrorCode(signal.payload), + ) + return retryBody, fallbackModel, true +} diff --git a/backend/internal/service/openai_compact_fallback_test.go b/backend/internal/service/openai_compact_fallback_test.go new file mode 100644 index 0000000000..2e4c7050eb --- /dev/null +++ b/backend/internal/service/openai_compact_fallback_test.go @@ -0,0 +1,405 @@ +package service + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func newOpenAICompactFallbackTestContext(t *testing.T, path string) *gin.Context { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, nil) + return c +} + +func TestPrepareOpenAICompactFallbackRetryRequiresExplicitCompact(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}} + c := newOpenAICompactFallbackTestContext(t, "/v1/responses") + body := []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":"hello"}]}`) + errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"maximum context length exceeded"}}`) + + retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry( + c, nil, "gpt-5.5", body, http.StatusBadRequest, "maximum context length exceeded", errorBody, false, + ) + + require.False(t, retry) + require.Empty(t, fallbackModel) + require.Equal(t, body, retryBody) +} + +func TestPrepareOpenAICompactFallbackRetryPreservesNativeTriggerAndContext(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}} + c := newOpenAICompactFallbackTestContext(t, "/v1/responses") + MarkOpenAINativeCompactionV2(c) + body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`) + errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`) + pathBefore := openAIResponsesRequestPathSuffix(c) + + retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry( + c, nil, "gpt-5.5", body, http.StatusBadRequest, "context window exceeded", errorBody, false, + ) + + require.True(t, retry) + require.Equal(t, "gpt-5.4", fallbackModel) + require.Equal(t, "gpt-5.4", gjson.GetBytes(retryBody, "model").String()) + require.True(t, HasCompactionTriggerInInput(retryBody)) + require.True(t, isOpenAINativeCompactionV2(c)) + require.Equal(t, pathBefore, openAIResponsesRequestPathSuffix(c)) +} + +func TestResolveOpenAICompactFallbackModelPrefersAccountMapping(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}}} + account := &Account{Credentials: map[string]any{ + "compact_model_mapping": map[string]any{"gpt-5.5": "account-compact"}, + }} + + require.Equal(t, "account-compact", svc.resolveOpenAICompactFallbackModel(account, "gpt-5.5")) + require.Equal(t, "global-compact", svc.resolveOpenAICompactFallbackModel(account, "unmapped-model")) +} + +func TestOpenAIGatewayForwardUsesGlobalCompactModelOnInitialLegacyRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[]}`) + c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact") + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"global-compact","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + require.Equal(t, "global-compact", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Contains(t, upstream.requests[0].URL.Path, "/compact") +} + +func TestPrepareOpenAICompactFallbackRetryLegacyPathAndSingleAttemptGuard(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}} + c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact") + body := []byte(`{"model":"gpt-5.5","input":[]}`) + errorBody := []byte(`{"response":{"status":"failed","error":null}}`) + + retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry( + c, nil, "gpt-5.5", body, http.StatusBadRequest, "", errorBody, false, + ) + require.True(t, retry) + require.Equal(t, "gpt-5.4", fallbackModel) + require.Equal(t, "/compact", openAIResponsesRequestPathSuffix(c)) + + secondBody, secondModel, secondRetry := svc.prepareOpenAICompactFallbackRetry( + c, nil, "gpt-5.5", retryBody, http.StatusBadRequest, "", errorBody, true, + ) + require.False(t, secondRetry) + require.Empty(t, secondModel) + require.Equal(t, retryBody, secondBody) +} + +func TestPrepareOpenAICompactFallbackRetryDoesNotHideSpecificBusinessFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}} + c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact") + body := []byte(`{"model":"gpt-5.5","input":[]}`) + errorBody := []byte(`{"response":{"status":"failed","error":{"type":"permission_error","message":"workspace denied"}}}`) + + retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry( + c, nil, "gpt-5.5", body, http.StatusBadRequest, "workspace denied", errorBody, false, + ) + + require.False(t, retry) + require.Empty(t, fallbackModel) + require.Equal(t, body, retryBody) +} + +func TestIsOpenAICompactModelFailureRequiresExplicitModelAvailabilityMessage(t *testing.T) { + tests := []struct { + name string + message string + want bool + }{ + {name: "explicit unsupported model", message: "The requested model is not supported", want: true}, + {name: "named missing model", message: "The model `gpt-5.5` does not exist", want: true}, + {name: "unsupported model code-like message", message: "unsupported model: gpt-5.5", want: true}, + {name: "unsupported model feature", message: "This model output format is not supported", want: false}, + {name: "unsupported parameter for model", message: "Parameter tools is not supported for this model", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isOpenAICompactModelFailure( + http.StatusBadRequest, + tt.message, + []byte(`{"error":{"message":`+strconv.Quote(tt.message)+`}}`), + )) + }) + } +} + +func TestPrepareOpenAICompactFallbackRetrySkipsSameModel(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.5"}}} + c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact") + body := []byte(`{"model":"gpt-5.5","input":[]}`) + errorBody := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + + _, _, retry := svc.prepareOpenAICompactFallbackRetry( + c, nil, "gpt-5.5", body, http.StatusNotFound, "model not found", errorBody, false, + ) + require.False(t, retry) +} + +func TestOpenAIGatewayForwardRetriesExplicitNativeCompactHTTPFailureOnce(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`) + c := newOpenAICompactFallbackTestContext(t, "/v1/responses") + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + MarkOpenAINativeCompactionV2(c) + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String()) + require.True(t, HasCompactionTriggerInInput(upstream.bodies[1])) + require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path) + require.NotContains(t, upstream.requests[1].URL.Path, "/compact") + rawEvents, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + events, ok := rawEvents.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.Len(t, events, 1) + require.Equal(t, "retry", events[0].Kind) + require.Equal(t, "compact_model_fallback", events[0].Reason) + require.Equal(t, http.StatusBadRequest, events[0].UpstreamStatusCode) +} + +func TestOpenAIGatewayForwardRetriesExplicitNativeCompactSSEFailureBeforeOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`) + c := newOpenAICompactFallbackTestContext(t, "/v1/responses") + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + MarkOpenAINativeCompactionV2(c) + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("event: response.failed\n" + + `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String()) + require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path) + require.NotContains(t, upstream.requests[1].URL.Path, "/compact") +} + +func TestOpenAIGatewayForwardRetriesStreamingCompactFailureBeforeOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + MarkOpenAINativeCompactionV2(c) + + failed := "event: response.failed\n" + + `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n" + completed := "event: response.completed\n" + + `data: {"type":"response.completed","response":{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed))}, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String()) + require.NotContains(t, recorder.Body.String(), "context_length_exceeded") + require.Contains(t, recorder.Body.String(), "response.completed") +} + +func TestOpenAIGatewayForwardDoesNotRecurseWhenCompactFallbackAlsoFails(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + MarkOpenAINativeCompactionV2(c) + + failed := "event: response.failed\n" + + `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"model_not_found","message":"model not found"}}}` + "\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String()) + var compactSignal *openAICompactFallbackSignal + require.False(t, errors.As(err, &compactSignal)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "model not found") + rawEvents, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + events, ok := rawEvents.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.Len(t, events, 2) + require.Equal(t, "retry", events[0].Kind) + require.Equal(t, "compact_model_fallback", events[0].Reason) + require.Equal(t, "http_error", events[1].Kind) +} + +func TestOpenAIPassthroughCompactFallbackSecondStreamFailureUsesStandardErrorPath(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("Content-Type", "application/json") + MarkOpenAINativeCompactionV2(c) + + failed := "event: response.failed\n" + + `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))}, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.forwardOpenAIPassthrough( + context.Background(), c, account, body, body, "gpt-5.5", false, nil, true, time.Now(), + ) + + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.bodies, 2) + var compactSignal *openAICompactFallbackSignal + require.False(t, errors.As(err, &compactSignal)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "context window exceeded") + rawEvents, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + events, ok := rawEvents.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.Len(t, events, 2) + require.Equal(t, "retry", events[0].Kind) + require.Equal(t, "compact_model_fallback", events[0].Reason) + require.Equal(t, "http_error", events[1].Kind) + require.True(t, events[1].Passthrough) +} diff --git a/backend/internal/service/openai_compact_model_mapping_test.go b/backend/internal/service/openai_compact_model_mapping_test.go index fc408e64af..f03fca1cd6 100644 --- a/backend/internal/service/openai_compact_model_mapping_test.go +++ b/backend/internal/service/openai_compact_model_mapping_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -39,6 +40,7 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr Credentials: map[string]any{ "access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc", + "model_mapping": map[string]any{"gpt-5.4": "gpt-5.3-codex"}, "compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"}, }, Status: StatusActive, @@ -51,6 +53,80 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr require.Equal(t, "gpt-5.4", result.Model) require.Equal(t, "gpt-5.4-openai-compact", result.UpstreamModel) require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String()) + opsModel, exists := c.Get(OpsUpstreamModelKey) + require.True(t, exists) + require.Equal(t, "gpt-5.4-openai-compact", opsModel) +} + +func TestOpenAIGatewayService_Forward_APIKeyCompactSanitizesStatelessReplayAfterStoreWasDropped(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","parallel_tool_calls":true,"input":[` + + `{"type":"reasoning","id":"rs_server_only","summary":[]},` + + `{"type":"item_reference","id":"rs_server_only"},` + + `{"type":"message","role":"user","content":"compact this"}` + + `]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_compact","status":"completed","output":[{"type":"compaction","encrypted_content":"cipher"}],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }}, + } + account := &Account{ + ID: 7, Name: "azure-openai", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "test-key"}, Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int()) + require.Equal(t, "message", gjson.GetBytes(upstream.lastBody, "input.0.type").String()) +} + +func TestOpenAIGatewayService_Forward_NormalizesCompactionTriggerAfterHistoryCleanup(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4","stream":false,"input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_trigger","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ + ID: 4, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + items := gjson.GetBytes(upstream.lastBody, "input").Array() + require.Len(t, items, 4) + require.Equal(t, "function_call", items[0].Get("type").String()) + require.Equal(t, "function_call_output", items[1].Get("type").String()) + require.Equal(t, "message", items[2].Get("type").String()) + require.Equal(t, "compaction_trigger", items[3].Get("type").String()) } func TestOpenAIGatewayService_Forward_NonCompactRequestIgnoresCompactOnlyModelMapping(t *testing.T) { @@ -132,4 +208,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactOnlyModelMappingOverridesU require.Equal(t, "gpt-5.4-openai-compact", result.UpstreamModel) require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "gpt-5.4", gjson.GetBytes(rec.Body.Bytes(), "model").String()) + opsModel, exists := c.Get(OpsUpstreamModelKey) + require.True(t, exists) + require.Equal(t, "gpt-5.4-openai-compact", opsModel) } diff --git a/backend/internal/service/openai_compact_probe.go b/backend/internal/service/openai_compact_probe.go index e8deff2d6d..9d5e8ccad4 100644 --- a/backend/internal/service/openai_compact_probe.go +++ b/backend/internal/service/openai_compact_probe.go @@ -10,7 +10,8 @@ import ( const ( // AccountTestModeDefault drives the standard /responses connection test. AccountTestModeDefault = "default" - // AccountTestModeCompact drives the /responses/compact compact-probe test. + // AccountTestModeCompact drives the remote-compaction probe test + // (native v2: streaming /responses with a compaction_trigger input item). AccountTestModeCompact = "compact" ) @@ -23,8 +24,12 @@ func normalizeAccountTestMode(mode string) string { } } -func createOpenAICompactProbePayload(model string) map[string]any { - return map[string]any{ +// createOpenAICompactProbePayload 构造原生 remote compaction v2 探测载荷: +// 流式 /responses + input 末尾 {"type":"compaction_trigger"}。上游已下线 +// legacy unary /responses/compact(v1 形态恒 404,#5598/#5624),现行 codex +// 默认协议即 v2(RemoteCompactionV2 Stable + default_enabled)。 +func createOpenAICompactProbePayload(model string, isOAuth bool) map[string]any { + payload := map[string]any{ "model": strings.TrimSpace(model), "instructions": "You are a helpful coding assistant.", "input": []any{ @@ -33,8 +38,35 @@ func createOpenAICompactProbePayload(model string) map[string]any { "role": "user", "content": "Respond with OK.", }, + map[string]any{"type": "compaction_trigger"}, }, + "stream": true, } + // ChatGPT internal API 要求 store: false,与真实转发一致。 + if isOAuth { + payload["store"] = false + } + return payload +} + +// openAICompactProbeFoundCompactionItem 判定探测响应是否产出了 compaction +// 输出 item——v2 契约的核心(codex 缺它即 fatal "got 0 items")。三种形态都 +// 认:① SSE 的 output_item.done/added(原生 v2 主形态,codex 只从这里收集 +// item);② SSE 终态 response.completed 的 response.output[](部分上游只在 +// 终态给出 item);③ 整体 JSON 的 output[](老网关链把请求降级成 unary)。 +func openAICompactProbeFoundCompactionItem(body []byte) bool { + if len(body) == 0 { + return false + } + bodyText := string(body) + if _, found := findRawCompactionItemFromSSE(bodyText); found { + return true + } + if finalResponse, ok := extractCodexFinalResponse(bodyText); ok && + responsesOutputHasCompactionItem(finalResponse) { + return true + } + return responsesOutputHasCompactionItem(body) } func shouldMarkOpenAICompactUnsupported(status int, body []byte) bool { @@ -60,7 +92,12 @@ func shouldMarkOpenAICompactUnsupported(status int, body []byte) bool { return false } -func buildOpenAICompactProbeExtraUpdates(resp *http.Response, body []byte, probeErr error, now time.Time) map[string]any { +// buildOpenAICompactProbeExtraUpdates 计算探测结果的账号 extra 更新。 +// compactionFound 是 v2 契约判据:HTTP 2xx 但响应无 compaction item 时同样 +// 记为不支持(链路把 compaction_trigger 吞掉的形态,等价 codex 的 "got 0 +// items" fatal,#5478/#5648)。极端场景(上游链只支持 legacy unary compact) +// 可用账号级 openai_compact_mode=force_on 人工覆盖。 +func buildOpenAICompactProbeExtraUpdates(resp *http.Response, body []byte, probeErr error, compactionFound bool, now time.Time) map[string]any { updates := map[string]any{ "openai_compact_checked_at": now.Format(time.RFC3339), "openai_compact_last_status": nil, @@ -84,10 +121,14 @@ func buildOpenAICompactProbeExtraUpdates(resp *http.Response, body []byte, probe errMsg = "HTTP " + strconv.Itoa(resp.StatusCode) } errMsg = truncateString(sanitizeUpstreamErrorMessage(errMsg), 2048) - if resp.StatusCode >= 200 && resp.StatusCode < 300 { + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300 && compactionFound: updates["openai_compact_supported"] = true updates["openai_compact_last_error"] = "" - } else { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + updates["openai_compact_supported"] = false + updates["openai_compact_last_error"] = "upstream returned 2xx without a compaction output item (native remote compaction v2 unsupported)" + default: if shouldMarkOpenAICompactUnsupported(resp.StatusCode, body) { updates["openai_compact_supported"] = false } @@ -112,9 +153,14 @@ func mergeExtraUpdates(base map[string]any, more map[string]any) map[string]any return out } +// compactProbeSessionID 返回探测请求使用的会话标识。真实 Codex 的 +// session-id / thread-id 恒为 UUID(codex-protocol ThreadId 是 UUIDv7), +// 探测既然与真实流量走同一个 /responses 端点,标识形态就必须同构—— +// 否则上游能凭 "probe_compact_5" 这类字面量一眼区分出探测流量。 +// 账号级稳定派生:重复探测复用同一会话,而不是每次新开一个。 func compactProbeSessionID(accountID int64) string { if accountID <= 0 { - return "probe_compact" + return deriveStableUUIDv4("sub2api:codex-compact-probe:v1:anonymous") } - return "probe_compact_" + strconv.FormatInt(accountID, 10) + return deriveStableUUIDv4("sub2api:codex-compact-probe:v1:" + strconv.FormatInt(accountID, 10)) } diff --git a/backend/internal/service/openai_compact_probe_test.go b/backend/internal/service/openai_compact_probe_test.go index fe3ba0e8d1..c273152e33 100644 --- a/backend/internal/service/openai_compact_probe_test.go +++ b/backend/internal/service/openai_compact_probe_test.go @@ -28,7 +28,7 @@ func TestNormalizeAccountTestMode(t *testing.T) { func TestBuildOpenAICompactProbeExtraUpdates_SuccessMarksSupported(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) - updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusOK}, []byte(`{"id":"cmp_1"}`), nil, now) + updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusOK}, []byte(`{"id":"cmp_1"}`), nil, true, now) if got := updates["openai_compact_supported"]; got != true { t.Fatalf("openai_compact_supported = %v, want true", got) @@ -47,7 +47,7 @@ func TestBuildOpenAICompactProbeExtraUpdates_SuccessMarksSupported(t *testing.T) func TestBuildOpenAICompactProbeExtraUpdates_404MarksUnsupported(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) body := []byte(`404 page not found`) - updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusNotFound}, body, nil, now) + updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusNotFound}, body, nil, false, now) if got := updates["openai_compact_supported"]; got != false { t.Fatalf("openai_compact_supported = %v, want false", got) @@ -59,7 +59,7 @@ func TestBuildOpenAICompactProbeExtraUpdates_404MarksUnsupported(t *testing.T) { func TestBuildOpenAICompactProbeExtraUpdates_502DoesNotMarkUnsupported(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) - updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusBadGateway}, []byte(`Upstream request failed`), nil, now) + updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusBadGateway}, []byte(`Upstream request failed`), nil, false, now) if _, exists := updates["openai_compact_supported"]; exists { t.Fatalf("did not expect openai_compact_supported for 502 response") @@ -71,7 +71,7 @@ func TestBuildOpenAICompactProbeExtraUpdates_502DoesNotMarkUnsupported(t *testin func TestBuildOpenAICompactProbeExtraUpdates_RequestErrorDoesNotMarkUnsupported(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) - updates := buildOpenAICompactProbeExtraUpdates(nil, nil, errors.New("dial tcp timeout"), now) + updates := buildOpenAICompactProbeExtraUpdates(nil, nil, errors.New("dial tcp timeout"), false, now) if _, exists := updates["openai_compact_supported"]; exists { t.Fatalf("did not expect openai_compact_supported for request error") @@ -86,7 +86,7 @@ func TestBuildOpenAICompactProbeExtraUpdates_RequestErrorDoesNotMarkUnsupported( func TestBuildOpenAICompactProbeExtraUpdates_NoResponseClearsLastStatus(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) - updates := buildOpenAICompactProbeExtraUpdates(nil, nil, nil, now) + updates := buildOpenAICompactProbeExtraUpdates(nil, nil, nil, false, now) if got, exists := updates["openai_compact_last_status"]; !exists || got != nil { t.Fatalf("openai_compact_last_status = %v, want nil key", got) @@ -99,7 +99,7 @@ func TestBuildOpenAICompactProbeExtraUpdates_NoResponseClearsLastStatus(t *testi func TestBuildOpenAICompactProbeExtraUpdates_UnknownModelDoesNotMarkUnsupported(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) body := []byte(`{"error":{"message":"unknown model gpt-5.4-openai-compact"}}`) - updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusBadRequest}, body, nil, now) + updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusBadRequest}, body, nil, false, now) if _, exists := updates["openai_compact_supported"]; exists { t.Fatalf("did not expect openai_compact_supported for unknown-model diagnostics") @@ -111,7 +111,7 @@ func TestBuildOpenAICompactProbeExtraUpdates_UnknownModelDoesNotMarkUnsupported( func TestBuildOpenAICompactProbeExtraUpdates_EmptyFailureBodyFallsBackToHTTPStatus(t *testing.T) { now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) - updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusServiceUnavailable}, nil, nil, now) + updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusServiceUnavailable}, nil, nil, false, now) if got := updates["openai_compact_last_status"]; got != http.StatusServiceUnavailable { t.Fatalf("openai_compact_last_status = %v, want %d", got, http.StatusServiceUnavailable) @@ -120,3 +120,79 @@ func TestBuildOpenAICompactProbeExtraUpdates_EmptyFailureBodyFallsBackToHTTPStat t.Fatalf("openai_compact_last_error = %v, want HTTP 503", got) } } + +func TestBuildOpenAICompactProbeExtraUpdates_2xxWithoutCompactionItemMarksUnsupported(t *testing.T) { + now := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) + updates := buildOpenAICompactProbeExtraUpdates(&http.Response{StatusCode: http.StatusOK}, []byte(`{"id":"resp_1","output":[]}`), nil, false, now) + + if got := updates["openai_compact_supported"]; got != false { + t.Fatalf("openai_compact_supported = %v, want false(2xx 无 compaction item = v2 不可用)", got) + } + if got := updates["openai_compact_last_error"]; got == "" { + t.Fatalf("expected openai_compact_last_error to explain the missing compaction item") + } +} + +func TestCreateOpenAICompactProbePayload_NativeV2Shape(t *testing.T) { + payload := createOpenAICompactProbePayload("gpt-5.6-sol", true) + if payload["stream"] != true { + t.Fatalf("v2 probe payload must be streaming") + } + if payload["store"] != false { + t.Fatalf("OAuth probe payload must carry store:false") + } + input, ok := payload["input"].([]any) + if !ok || len(input) != 2 { + t.Fatalf("expected 2 input items, got %v", payload["input"]) + } + last, ok := input[len(input)-1].(map[string]any) + if !ok || last["type"] != "compaction_trigger" { + t.Fatalf("last input item must be compaction_trigger, got %v", input[len(input)-1]) + } + + apiKeyPayload := createOpenAICompactProbePayload("gpt-5.6-sol", false) + if _, has := apiKeyPayload["store"]; has { + t.Fatalf("API-key probe payload must not force store") + } +} + +func TestOpenAICompactProbeFoundCompactionItem(t *testing.T) { + sseWithItem := []byte("event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"id\":\"cmp_1\",\"encrypted_content\":\"blob\"}}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"output\":[]}}\n\n") + if !openAICompactProbeFoundCompactionItem(sseWithItem) { + t.Fatalf("SSE output_item.done 携带 compaction item 应判定为支持") + } + + sseAlias := []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction_summary\",\"id\":\"cmp_2\"}}\n\n") + if !openAICompactProbeFoundCompactionItem(sseAlias) { + t.Fatalf("compaction_summary 别名应判定为支持") + } + + sseNoItem := []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_1\"}}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_2\",\"output\":[]}}\n\n") + if openAICompactProbeFoundCompactionItem(sseNoItem) { + t.Fatalf("无 compaction item 的流不应判定为支持") + } + + jsonWithItem := []byte(`{"id":"resp_3","output":[{"type":"compaction","id":"cmp_3"}]}`) + if !openAICompactProbeFoundCompactionItem(jsonWithItem) { + t.Fatalf("JSON output[] 携带 compaction item 应判定为支持(降级链兜底)") + } + + if openAICompactProbeFoundCompactionItem(nil) { + t.Fatalf("空响应不应判定为支持") + } +} + +func TestOpenAICompactProbeFoundCompactionItem_TerminalResponseOutput(t *testing.T) { + // 部分上游只在终态 response.completed 的 output[] 给出 compaction item, + // 事件流里没有 output_item.done——探针同样必须判定为支持。 + sseTerminalOnly := []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_1\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_t\",\"output\":[{\"type\":\"compaction\",\"id\":\"cmp_t\"}]}}\n\n") + if !openAICompactProbeFoundCompactionItem(sseTerminalOnly) { + t.Fatalf("终态 response.output 中的 compaction item 应判定为支持") + } + + sseTerminalEmpty := []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_e\",\"output\":[]}}\n\n") + if openAICompactProbeFoundCompactionItem(sseTerminalEmpty) { + t.Fatalf("终态 output 为空且无 item 事件时不应判定为支持") + } +} diff --git a/backend/internal/service/openai_compact_sse_keepalive.go b/backend/internal/service/openai_compact_sse_keepalive.go index 70e6af9454..360c1d55a5 100644 --- a/backend/internal/service/openai_compact_sse_keepalive.go +++ b/backend/internal/service/openai_compact_sse_keepalive.go @@ -161,21 +161,28 @@ func OpenAICompactKeepaliveAdjustedWrittenSize(c *gin.Context) int { if c == nil || c.Writer == nil { return -1 } - value, ok := c.Get(openAICompactSSEKeepaliveKey) - if !ok { - return c.Writer.Size() + streamKeepaliveBytes := 0 + if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok { + streamKeepaliveBytes, _ = value.(int) } - k, ok := value.(*openAICompactSSEKeepalive) - if !ok || k == nil { - return c.Writer.Size() + size := c.Writer.Size() + compactKeepaliveBytes := 0 + if value, ok := c.Get(openAICompactSSEKeepaliveKey); ok { + if k, valid := value.(*openAICompactSSEKeepalive); valid && k != nil { + k.mu.Lock() + size = k.writer.Size() + compactKeepaliveBytes = k.bytes + k.mu.Unlock() + } } - k.mu.Lock() - defer k.mu.Unlock() - size := k.writer.Size() if size < 0 { return size } - if real := size - k.bytes; real > 0 { + keepaliveBytes := compactKeepaliveBytes + streamKeepaliveBytes + if keepaliveBytes <= 0 { + return size + } + if real := size - keepaliveBytes; real > 0 { return real } return -1 diff --git a/backend/internal/service/openai_compact_sse_keepalive_test.go b/backend/internal/service/openai_compact_sse_keepalive_test.go index 95ee4ce76c..3703ea9f2a 100644 --- a/backend/internal/service/openai_compact_sse_keepalive_test.go +++ b/backend/internal/service/openai_compact_sse_keepalive_test.go @@ -71,6 +71,20 @@ func TestOpenAICompactSSEKeepalive_StopBeforeFirstBeatKeepsWriterUntouched(t *te require.False(t, StopOpenAICompactSSEKeepaliveCommitted(c)) } +func TestOpenAIAdjustedWrittenSizeExcludesResponsesStreamKeepalive(t *testing.T) { + c, rec := newCompactBridgeTestContext(t, false) + n, err := c.Writer.Write([]byte(":\n\n")) + require.NoError(t, err) + recordOpenAIStreamKeepaliveBytes(c, n) + + require.Equal(t, -1, OpenAICompactKeepaliveAdjustedWrittenSize(c)) + + _, err = c.Writer.Write([]byte("data: semantic\n\n")) + require.NoError(t, err) + require.Equal(t, len("data: semantic\n\n"), OpenAICompactKeepaliveAdjustedWrittenSize(c)) + require.Equal(t, ":\n\ndata: semantic\n\n", rec.Body.String()) +} + // 心跳已提交后,2xx 桥接续写事件而不重复提交响应头。 func TestWriteOpenAICompactSSEBridge_AfterKeepaliveCommitAppendsEvents(t *testing.T) { c, rec := newCompactBridgeTestContext(t, true) diff --git a/backend/internal/service/openai_compact_stream_bridge_test.go b/backend/internal/service/openai_compact_stream_bridge_test.go index bf3416192e..3a095a6367 100644 --- a/backend/internal/service/openai_compact_stream_bridge_test.go +++ b/backend/internal/service/openai_compact_stream_bridge_test.go @@ -318,7 +318,7 @@ func TestHandlePassthroughSSEToJSON_CompactRawOutputItemDoneRepairsEmptyTerminal Body: io.NopCloser(strings.NewReader(upstreamSSE)), } - result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, "gpt-5.5", "") + result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "") require.NoError(t, err) require.NotNil(t, result) @@ -512,7 +512,7 @@ func TestHandleNonStreamingResponsePassthrough_CompactClientStreamBridgesToSSE(t }`)), } - result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, "gpt-5.5", "") + result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "") require.NoError(t, err) require.NotNil(t, result) diff --git a/backend/internal/service/openai_compaction_context.go b/backend/internal/service/openai_compaction_context.go new file mode 100644 index 0000000000..360ec4ecdb --- /dev/null +++ b/backend/internal/service/openai_compaction_context.go @@ -0,0 +1,32 @@ +package service + +import "context" + +type openAIForwardModelContextKey struct{} + +type openAIForwardModel struct { + model string + useCompactModelMapping bool +} + +// WithOpenAIForwardModel records the model present in the forwarded request +// body after channel mapping and whether the legacy /responses/compact-only +// model mapping applies. Native remote compaction v2 keeps this false, so +// channel restriction checks follow the same model chain used by Forward. +func WithOpenAIForwardModel(ctx context.Context, forwardModel string, useCompactModelMapping bool) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, openAIForwardModelContextKey{}, openAIForwardModel{ + model: forwardModel, + useCompactModelMapping: useCompactModelMapping, + }) +} + +func openAIForwardModelFromContext(ctx context.Context) (openAIForwardModel, bool) { + if ctx == nil { + return openAIForwardModel{}, false + } + forwardModel, ok := ctx.Value(openAIForwardModelContextKey{}).(openAIForwardModel) + return forwardModel, ok +} diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 7ccfbdeadd..b52a616f85 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1010,7 +1010,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) diff --git a/backend/internal/service/openai_content_session_seed.go b/backend/internal/service/openai_content_session_seed.go index 5f8f1872e8..2acf298721 100644 --- a/backend/internal/service/openai_content_session_seed.go +++ b/backend/internal/service/openai_content_session_seed.go @@ -18,8 +18,9 @@ const contentStablePrefixSessionSeedPrefix = "compat_csp_" // deriveOpenAIContentSessionSeed builds a stable session seed from an // OpenAI-format request body. Only fields constant across conversation turns -// are included: model, tools/functions definitions, system/developer prompts, -// instructions (Responses API), and the first user message. +// are included: model, tools/functions definitions, the leading system/developer +// prompt prefix in Chat messages, instructions (Responses API), and the first +// user message. // Supports both Chat Completions (messages) and Responses API (input). func deriveOpenAIContentSessionSeed(body []byte) string { if len(body) == 0 { @@ -117,15 +118,19 @@ scanRoot: msgs := fields[messagesField] if msgs.Exists() && msgs.IsArray() { + systemPrefixOpen := true msgs.ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").String() switch role { case "system", "developer": - _, _ = b.WriteString("|system=") - if c := msg.Get("content"); c.Exists() { - _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + if systemPrefixOpen { + _, _ = b.WriteString("|system=") + if c := msg.Get("content"); c.Exists() { + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } } case "user": + systemPrefixOpen = false if !firstUserCaptured { _, _ = b.WriteString("|first_user=") if c := msg.Get("content"); c.Exists() { @@ -133,6 +138,8 @@ scanRoot: } firstUserCaptured = true } + default: + systemPrefixOpen = false } return true }) diff --git a/backend/internal/service/openai_content_session_seed_test.go b/backend/internal/service/openai_content_session_seed_test.go index 0c6c63335c..5b621e3ce9 100644 --- a/backend/internal/service/openai_content_session_seed_test.go +++ b/backend/internal/service/openai_content_session_seed_test.go @@ -44,6 +44,65 @@ func TestDeriveOpenAIContentSessionSeed_ChatCompletions_StableAcrossTurns(t *tes require.NotEmpty(t, s1) } +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_IgnoresLaterSystemMessages(t *testing.T) { + turn1 := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"} + ] + }`) + turn2 := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "system", "content": "Return JSON for this turn."}, + {"role": "user", "content": "How are you?"} + ] + }`) + + require.Equal(t, deriveOpenAIContentSessionSeed(turn1), deriveOpenAIContentSessionSeed(turn2)) +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_UsesLeadingSystemDeveloperPrefix(t *testing.T) { + firstSystem := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "System A"}, + {"role": "developer", "content": "Developer B"}, + {"role": "user", "content": "Hello"} + ] + }`) + changedLaterSystem := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "System A"}, + {"role": "developer", "content": "Developer C"}, + {"role": "user", "content": "Hello"} + ] + }`) + + seed := deriveOpenAIContentSessionSeed(firstSystem) + require.Contains(t, seed, "System A") + require.Contains(t, seed, "Developer B") + require.NotEqual(t, seed, deriveOpenAIContentSessionSeed(changedLaterSystem)) + + withLaterSystem := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "System A"}, + {"role": "developer", "content": "Developer B"}, + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "Dynamic system"} + ] + }`) + require.Equal(t, seed, deriveOpenAIContentSessionSeed(withLaterSystem)) +} + func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentFirstUserDiffers(t *testing.T) { req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question A"}]}`) req2 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question B"}]}`) @@ -270,7 +329,7 @@ func TestDeriveOpenAIContentSessionSeed_SingleScanMatchesLegacyBytes(t *testing. for _, test := range tests { t.Run(test.name, func(t *testing.T) { - require.Equal(t, legacyDeriveOpenAIContentSessionSeed(test.body), deriveOpenAIContentSessionSeed(test.body)) + require.Equal(t, referenceDeriveOpenAIContentSessionSeed(test.body), deriveOpenAIContentSessionSeed(test.body)) }) } } @@ -283,12 +342,12 @@ func TestDeriveOpenAIContentSessionSeed_AllTruncationOffsetsMatchLegacyBytes(t * for bodyIndex, body := range bodies { for end := 1; end < len(body); end++ { truncated := []byte(body[:end]) - require.Equalf(t, legacyDeriveOpenAIContentSessionSeed(truncated), deriveOpenAIContentSessionSeed(truncated), "body %d truncated at byte %d", bodyIndex, end) + require.Equalf(t, referenceDeriveOpenAIContentSessionSeed(truncated), deriveOpenAIContentSessionSeed(truncated), "body %d truncated at byte %d", bodyIndex, end) } } } -func legacyDeriveOpenAIContentSessionSeed(body []byte) string { +func referenceDeriveOpenAIContentSessionSeed(body []byte) string { if len(body) == 0 { return "" } @@ -319,15 +378,19 @@ func legacyDeriveOpenAIContentSessionSeed(body []byte) string { msgs := gjson.GetBytes(body, "messages") if msgs.Exists() && msgs.IsArray() { + systemPrefixOpen := true msgs.ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").String() switch role { case "system", "developer": - _, _ = b.WriteString("|system=") - if c := msg.Get("content"); c.Exists() { - _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + if systemPrefixOpen { + _, _ = b.WriteString("|system=") + if c := msg.Get("content"); c.Exists() { + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } } case "user": + systemPrefixOpen = false if !firstUserCaptured { _, _ = b.WriteString("|first_user=") if c := msg.Get("content"); c.Exists() { @@ -335,6 +398,8 @@ func legacyDeriveOpenAIContentSessionSeed(body []byte) string { } firstUserCaptured = true } + default: + systemPrefixOpen = false } return true }) diff --git a/backend/internal/service/openai_cyber_session_block.go b/backend/internal/service/openai_cyber_session_block.go index ebca8b1967..44c3ece444 100644 --- a/backend/internal/service/openai_cyber_session_block.go +++ b/backend/internal/service/openai_cyber_session_block.go @@ -4,6 +4,8 @@ import ( "context" "crypto/sha256" "encoding/hex" + "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" @@ -14,16 +16,52 @@ import ( // repository 层 gatewayCache 附带实现(类型断言探测接入,不改 GatewayCache // 共享接口);测试 stub 不实现时屏蔽能力自动降级关闭。 type CyberSessionBlockStore interface { - SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error - IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) + SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error + IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) + FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) } -// CyberSessionBlockKey 派生会话屏蔽 key:仅用显式会话标识(header -// session_id/conversation_id 或 body prompt_cache_key),混入 apiKeyID 隔离后 -// sha256。无显式标识返回空串——调用方必须放行(粒度决策:不退化到 -// user/apikey/内容派生)。 -func CyberSessionBlockKey(apiKeyID int64, c *gin.Context, body []byte) string { - raw := explicitOpenAISessionID(c, body) +const cyberSessionTranscriptLookupOverflowBlockKey = "transcript_lookup_limit_exceeded" + +// CyberSessionExplicitBlockKey returns an inexpensive exact key when the +// client supplies a stable session signal. +func CyberSessionExplicitBlockKey(apiKeyID int64, c *gin.Context, body []byte) string { + return hashCyberSessionBlockKey(apiKeyID, explicitOpenAISessionID(c, body)) +} + +// CyberSessionTranscriptBlockKeys returns the exact full-request key followed +// by an optional rewrite-tolerant context key. The latter is emitted only after +// model-generated history has been observed. +func CyberSessionTranscriptBlockKeys(apiKeyID int64, body []byte) []string { + derived := deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body) + if len(derived.lookupKeys) == 0 { + return nil + } + keys := []string{derived.lookupKeys[len(derived.lookupKeys)-1]} + if derived.preLatestUserKey != "" && derived.preLatestUserKey != keys[0] { + keys = append(keys, derived.preLatestUserKey) + } + return keys +} + +func CyberSessionTranscriptLookupKeys(apiKeyID int64, body []byte) []string { + return deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body).lookupKeys +} + +// CyberSessionScopeKey is a coarse, non-blocking fingerprint used only to +// avoid transcript parsing and MGET for sources that never produced a hit. +func CyberSessionScopeKey(apiKeyID int64, clientIP, userAgent string) string { + if apiKeyID <= 0 { + return "" + } + raw := "cyber-scope:v1|api_key=" + strconv.FormatInt(apiKeyID, 10) + + "|ip=" + strings.TrimSpace(clientIP) + + "|ua=" + NormalizeSessionUserAgent(userAgent) + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func hashCyberSessionBlockKey(apiKeyID int64, raw string) string { if raw == "" { return "" } @@ -59,8 +97,8 @@ func (s *OpenAIGatewayService) CyberSessionBlockRuntime(ctx context.Context) (bo // MarkCyberSessionBlocked 把会话写入屏蔽表(写入点:cyber 命中后)。 // 开关关闭、key 为空或存储不可用时静默跳过。 -func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key string) { - if key == "" { +func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string) { + if s == nil || len(keys) == 0 { return } enabled, ttl := s.CyberSessionBlockRuntime(ctx) @@ -71,29 +109,55 @@ func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key if store == nil { return } - if err := store.SetCyberSessionBlocked(ctx, key, ttl); err != nil { + if err := store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl); err != nil { logger.LegacyPrintf("service.openai_gateway", "cyber session block write failed: err=%v", err) } } -// IsCyberSessionBlocked 查询会话是否被屏蔽(拦截点)。开关关闭、key 为空、 -// 存储不可用或查询出错时返回 false(fail-open:屏蔽是增强防护,不阻断主链路)。 -func (s *OpenAIGatewayService) IsCyberSessionBlocked(ctx context.Context, key string) bool { - if key == "" { - return false - } +// FindCyberSessionBlockedForRequest applies explicit-first lookup followed by +// scope-gated transcript matching. All failures remain fail-open. +func (s *OpenAIGatewayService) FindCyberSessionBlockedForRequest(ctx context.Context, apiKeyID int64, c *gin.Context, body []byte, clientIP, userAgent string) string { enabled, _ := s.CyberSessionBlockRuntime(ctx) if !enabled { - return false + return "" } store := s.cyberSessionBlockStore() if store == nil { - return false + return "" } - blocked, err := store.IsCyberSessionBlocked(ctx, key) + if explicitKey := CyberSessionExplicitBlockKey(apiKeyID, c, body); explicitKey != "" { + key, err := store.FindCyberSessionBlocked(ctx, []string{explicitKey}) + if err != nil { + logger.LegacyPrintf("service.openai_gateway", "cyber explicit session read failed: err=%v", err) + return "" + } + if key != "" { + return key + } + } + scopeKey := CyberSessionScopeKey(apiKeyID, clientIP, userAgent) + active, err := store.IsCyberSessionScopeActive(ctx, scopeKey) if err != nil { - logger.LegacyPrintf("service.openai_gateway", "cyber session block read failed: err=%v", err) - return false + logger.LegacyPrintf("service.openai_gateway", "cyber session scope read failed: err=%v", err) + return "" } - return blocked + if !active { + return "" + } + transcript := deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body) + if transcript.lookupKeysTruncated { + // Once the coarse scope is active, silently dropping old candidates would + // let a blocked client evade prefix matching by appending dummy items. + return cyberSessionTranscriptLookupOverflowBlockKey + } + keys := transcript.lookupKeys + if len(keys) == 0 { + return "" + } + key, err := store.FindCyberSessionBlocked(ctx, keys) + if err != nil { + logger.LegacyPrintf("service.openai_gateway", "cyber session block batch read failed: err=%v", err) + return "" + } + return key } diff --git a/backend/internal/service/openai_cyber_session_block_test.go b/backend/internal/service/openai_cyber_session_block_test.go index 6d6ad8bad4..aff46df25d 100644 --- a/backend/internal/service/openai_cyber_session_block_test.go +++ b/backend/internal/service/openai_cyber_session_block_test.go @@ -2,8 +2,10 @@ package service import ( "context" + "encoding/json" "errors" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -23,57 +25,116 @@ func newCyberBlockTestCtx(headers map[string]string, body string) (*gin.Context, return c, []byte(body) } -// TestCyberSessionBlockKey verifies F5a key derivation: explicit session signals -// only (header session_id/conversation_id or body prompt_cache_key), apiKey -// isolated, and EMPTY when no explicit signal (no content-derived fallback — -// "不退化" decision). -func TestCyberSessionBlockKey(t *testing.T) { +func TestCyberSessionExplicitBlockKey(t *testing.T) { c1, b1 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`) - k1 := CyberSessionBlockKey(101, c1, b1) + k1 := CyberSessionExplicitBlockKey(101, c1, b1) require.NotEmpty(t, k1) // Same session, different apiKey → different key (isolation). c2, b2 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`) - require.NotEqual(t, k1, CyberSessionBlockKey(202, c2, b2)) + require.NotEqual(t, k1, CyberSessionExplicitBlockKey(202, c2, b2)) // Same session + same apiKey → stable key. c3, b3 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`) - require.Equal(t, k1, CyberSessionBlockKey(101, c3, b3)) + require.Equal(t, k1, CyberSessionExplicitBlockKey(101, c3, b3)) // prompt_cache_key in body counts as explicit. c4, b4 := newCyberBlockTestCtx(nil, `{"prompt_cache_key":"pck-1"}`) - require.NotEmpty(t, CyberSessionBlockKey(101, c4, b4)) + require.NotEmpty(t, CyberSessionExplicitBlockKey(101, c4, b4)) // No explicit signal → empty key → caller must skip blocking entirely. c5, b5 := newCyberBlockTestCtx(nil, `{"input":"hello world"}`) - require.Empty(t, CyberSessionBlockKey(101, c5, b5)) + require.Empty(t, CyberSessionExplicitBlockKey(101, c5, b5)) // conversation_id header counts as explicit; key is stable and non-empty. c6, b6 := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`) - k6 := CyberSessionBlockKey(101, c6, b6) + k6 := CyberSessionExplicitBlockKey(101, c6, b6) require.NotEmpty(t, k6) c6b, b6b := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`) - require.Equal(t, k6, CyberSessionBlockKey(101, c6b, b6b), "conversation_id key must be stable") + require.Equal(t, k6, CyberSessionExplicitBlockKey(101, c6b, b6b), "conversation_id key must be stable") +} + +func TestCyberTranscriptBlockKeysRequireModelGeneratedHistory(t *testing.T) { + first := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question one"}]}`) + second := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question two"}]}`) + firstKeys := CyberSessionTranscriptBlockKeys(77, first) + secondKeys := CyberSessionTranscriptBlockKeys(77, second) + require.Len(t, firstKeys, 1) + require.Len(t, secondKeys, 1) + require.NotEqual(t, firstKeys[0], secondKeys[0]) + + hit := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`) + continuation := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`) + hitKeys := CyberSessionTranscriptBlockKeys(77, hit) + require.Len(t, hitKeys, 2) + require.Contains(t, CyberSessionTranscriptLookupKeys(77, continuation), hitKeys[1]) +} + +func TestCyberTranscriptBlockKeysWebSocketResponseCreate(t *testing.T) { + body := []byte(`{"type":"response.create","response":{"prompt_cache_key":"ws-session","input":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}}`) + c, _ := newCyberBlockTestCtx(nil, string(body)) + require.NotEmpty(t, CyberSessionExplicitBlockKey(88, c, body)) + require.Len(t, CyberSessionTranscriptBlockKeys(88, body), 2) +} + +func TestCyberTranscriptLookupKeysAreBoundedAndKeepNewestOrder(t *testing.T) { + messages := make([]map[string]string, maxOpenAICyberTranscriptLookupKeys+44) + for i := range messages { + messages[i] = map[string]string{"role": "user", "content": "message-" + strconv.Itoa(i)} + } + body, err := json.Marshal(map[string]any{"messages": messages}) + require.NoError(t, err) + + keys := CyberSessionTranscriptLookupKeys(77, body) + require.Len(t, keys, maxOpenAICyberTranscriptLookupKeys) + + firstRetainedBody, err := json.Marshal(map[string]any{"messages": messages[:45]}) + require.NoError(t, err) + firstRetainedPrefix := CyberSessionTranscriptLookupKeys(77, firstRetainedBody) + require.Equal(t, firstRetainedPrefix[len(firstRetainedPrefix)-1], keys[0]) + + fullKey := CyberSessionTranscriptBlockKeys(77, body)[0] + require.Equal(t, fullKey, keys[len(keys)-1]) } // --- fakes --- type fakeCyberBlockStore struct { - blocked map[string]bool + blocked map[string]bool + scopes map[string]bool + findCalls int } var _ CyberSessionBlockStore = (*fakeCyberBlockStore)(nil) -func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, key string, _ time.Duration) error { +func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, scopeKey string, keys []string, _ time.Duration) error { if f.blocked == nil { f.blocked = map[string]bool{} } - f.blocked[key] = true + for _, key := range keys { + f.blocked[key] = true + } + if scopeKey != "" { + if f.scopes == nil { + f.scopes = map[string]bool{} + } + f.scopes[scopeKey] = true + } return nil } -func (f *fakeCyberBlockStore) IsCyberSessionBlocked(_ context.Context, key string) (bool, error) { - return f.blocked[key], nil +func (f *fakeCyberBlockStore) IsCyberSessionScopeActive(_ context.Context, scopeKey string) (bool, error) { + return f.scopes[scopeKey], nil +} + +func (f *fakeCyberBlockStore) FindCyberSessionBlocked(_ context.Context, keys []string) (string, error) { + f.findCalls++ + for _, key := range keys { + if f.blocked[key] { + return key, nil + } + } + return "", nil } // fakeSettingRepo is a minimal SettingRepository stub for unit tests. @@ -148,25 +209,34 @@ func (c *comboCacheAndStore) ReleaseGrokVideoBilled(_ context.Context, _ string) return nil } -func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error { - return c.store.SetCyberSessionBlocked(ctx, key, ttl) +func (c *comboCacheAndStore) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil } -func (c *comboCacheAndStore) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) { - return c.store.IsCyberSessionBlocked(ctx, key) +func (c *comboCacheAndStore) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + +func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error { + return c.store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl) +} +func (c *comboCacheAndStore) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) { + return c.store.IsCyberSessionScopeActive(ctx, scopeKey) +} +func (c *comboCacheAndStore) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) { + return c.store.FindCyberSessionBlocked(ctx, keys) } // --- tests --- // TestIsCyberSessionBlocked_EmptyKeyAndNilService covers the fail-open paths: // empty key, nil service, store missing → always false / no panic. -func TestIsCyberSessionBlocked_EmptyKeyAndNilService(t *testing.T) { +func TestFindCyberSessionBlocked_EmptyAndNilService(t *testing.T) { var nilSvc *OpenAIGatewayService - require.False(t, nilSvc.IsCyberSessionBlocked(context.Background(), "k")) - require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "k") }) + require.Empty(t, nilSvc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", "")) + require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "", []string{"k"}) }) svc := &OpenAIGatewayService{} - require.False(t, svc.IsCyberSessionBlocked(context.Background(), "")) - require.False(t, svc.IsCyberSessionBlocked(context.Background(), "k"), "no store + no settings → fail-open false") + require.Empty(t, svc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", "")) } // TestCyberSessionBlock_RoundTrip exercises the type-assertion success path: @@ -193,15 +263,70 @@ func TestCyberSessionBlock_RoundTrip(t *testing.T) { ctx := context.Background() const testKey = "deadbeef1234" - // Before marking: not blocked. - require.False(t, svc.IsCyberSessionBlocked(ctx, testKey)) + c, body := newCyberBlockTestCtx(map[string]string{"session_id": "sess-roundtrip"}, `{}`) + explicitKey := CyberSessionExplicitBlockKey(1, c, body) + require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0")) - // Mark as blocked. - svc.MarkCyberSessionBlocked(ctx, testKey) + svc.MarkCyberSessionBlocked(ctx, "", []string{explicitKey, testKey}) - // After marking: blocked. - require.True(t, svc.IsCyberSessionBlocked(ctx, testKey)) + require.Equal(t, explicitKey, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0")) +} - // Different key: still not blocked. - require.False(t, svc.IsCyberSessionBlocked(ctx, "other-key")) +func TestFindCyberSessionBlockedForRequestUsesScopeForTranscript(t *testing.T) { + settingSvc := &SettingService{settingRepo: &fakeSettingRepo{vals: map[string]string{ + SettingKeyCyberSessionBlockEnabled: "true", + SettingKeyCyberSessionBlockTTLSeconds: "60", + }}} + combo := &comboCacheAndStore{} + svc := &OpenAIGatewayService{cache: combo, settingService: settingSvc} + ctx := context.Background() + + hitBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`) + nextBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`) + nextCtx, _ := newCyberBlockTestCtx(nil, string(nextBody)) + const clientIP = "203.0.113.20" + const userAgent = "Codex CLI 1.2.3" + blockKey := CyberSessionTranscriptBlockKeys(9, hitBody)[1] + + // Without an active source scope, transcript candidates are never blocks. + require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, userAgent)) + scopeKey := CyberSessionScopeKey(9, clientIP, userAgent) + svc.MarkCyberSessionBlocked(ctx, scopeKey, []string{blockKey}) + require.Equal(t, blockKey, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, "Codex CLI 1.2.4")) +} + +func TestFindCyberSessionBlockedForRequestFailsClosedOnScopedTranscriptOverflow(t *testing.T) { + settingSvc := &SettingService{settingRepo: &fakeSettingRepo{vals: map[string]string{ + SettingKeyCyberSessionBlockEnabled: "true", + SettingKeyCyberSessionBlockTTLSeconds: "60", + }}} + combo := &comboCacheAndStore{} + svc := &OpenAIGatewayService{cache: combo, settingService: settingSvc} + ctx := context.Background() + const apiKeyID = int64(9) + const clientIP = "203.0.113.20" + const userAgent = "Codex CLI 1.2.3" + + messages := make([]map[string]string, maxOpenAICyberTranscriptLookupKeys+1) + for i := range messages { + messages[i] = map[string]string{"role": "user", "content": "message-" + strconv.Itoa(i)} + } + body, err := json.Marshal(map[string]any{"messages": messages}) + require.NoError(t, err) + c, _ := newCyberBlockTestCtx(nil, string(body)) + require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, apiKeyID, c, body, clientIP, userAgent), + "overflow alone must not bypass the scope gate") + combo.store.scopes = map[string]bool{CyberSessionScopeKey(apiKeyID, clientIP, userAgent): true} + + require.Equal(t, cyberSessionTranscriptLookupOverflowBlockKey, + svc.FindCyberSessionBlockedForRequest(ctx, apiKeyID, c, body, clientIP, userAgent)) + require.Zero(t, combo.store.findCalls, "overflow must not issue an unbounded Redis lookup") +} + +func TestCyberSessionScopeKeyNormalizesUserAgentVersion(t *testing.T) { + base := CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.3") + require.NotEmpty(t, base) + require.Equal(t, base, CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.4")) + require.NotEqual(t, base, CyberSessionScopeKey(8, "203.0.113.10", "Codex CLI 1.2.3")) + require.NotEqual(t, base, CyberSessionScopeKey(7, "203.0.113.11", "Codex CLI 1.2.3")) } diff --git a/backend/internal/service/openai_cyber_transcript.go b/backend/internal/service/openai_cyber_transcript.go new file mode 100644 index 0000000000..b286c04769 --- /dev/null +++ b/backend/internal/service/openai_cyber_transcript.go @@ -0,0 +1,157 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strconv" + "strings" + + "github.com/tidwall/gjson" +) + +type openAICyberTranscriptBlockKeys struct { + lookupKeys []string + preLatestUserKey string + lookupKeysTruncated bool +} + +// Bound the Redis lookup work for a single request while retaining the most +// recent transcript prefixes, where a continuation is most likely to match. +const maxOpenAICyberTranscriptLookupKeys = 256 + +// deriveOpenAICyberTranscriptBlockKeys returns cumulative semantic-history +// hashes plus the context key immediately before the latest user turn. The +// context key requires model-generated history so shared first-turn templates +// cannot block unrelated conversations. +func deriveOpenAICyberTranscriptBlockKeys(apiKeyID int64, body []byte) openAICyberTranscriptBlockKeys { + if len(body) == 0 { + return openAICyberTranscriptBlockKeys{} + } + root := openAIRequestPayloadView(body) + if !root.Exists() || !root.IsObject() { + return openAICyberTranscriptBlockKeys{} + } + + h := sha256.New() + _, _ = h.Write([]byte("cyber-transcript:v3|api_key=")) + _, _ = h.Write([]byte(strconv.FormatInt(apiKeyID, 10))) + // Model and tool definitions are request configuration rather than history. + // Root instructions remain model-visible context and participate in identity. + for _, field := range []string{"instructions"} { + v := root.Get(field) + if !v.Exists() || (v.Type == gjson.String && strings.TrimSpace(v.String()) == "") { + continue + } + canonical := normalizeCompatSeedJSON(json.RawMessage(v.Raw)) + if v.Type == gjson.String { + canonical = v.String() + } + _, _ = h.Write([]byte("|")) + _, _ = h.Write([]byte(field)) + _, _ = h.Write([]byte("=")) + _, _ = h.Write([]byte(canonical)) + } + + appendSequence := func(sequence gjson.Result) openAICyberTranscriptBlockKeys { + if !sequence.Exists() || !sequence.IsArray() { + return openAICyberTranscriptBlockKeys{} + } + result := openAICyberTranscriptBlockKeys{ + lookupKeys: make([]string, 0, maxOpenAICyberTranscriptLookupKeys), + } + nextLookupKey := 0 + lookupKeysRotated := false + lastLookupKey := "" + // This is an entropy heuristic, not provenance proof: authenticated + // server-side history would be required to distinguish fixed few-shot + // assistant items perfectly. + hasModelGeneratedItem := false + sequence.ForEach(func(_, item gjson.Result) bool { + canonical := item.Raw + switch item.Type { + case gjson.String: + encoded, _ := json.Marshal(item.String()) + canonical = string(encoded) + case gjson.JSON: + canonical = normalizeCompatSeedJSON(json.RawMessage(item.Raw)) + } + if strings.TrimSpace(canonical) == "" { + return true + } + if openAICyberTranscriptItemStartsUserTurn(item) && hasModelGeneratedItem && lastLookupKey != "" { + result.preLatestUserKey = lastLookupKey + } + _, _ = h.Write([]byte("|item=")) + _, _ = h.Write([]byte(canonical)) + lastLookupKey = hex.EncodeToString(h.Sum(nil)) + if len(result.lookupKeys) < maxOpenAICyberTranscriptLookupKeys { + result.lookupKeys = append(result.lookupKeys, lastLookupKey) + } else { + result.lookupKeys[nextLookupKey] = lastLookupKey + nextLookupKey = (nextLookupKey + 1) % maxOpenAICyberTranscriptLookupKeys + lookupKeysRotated = true + result.lookupKeysTruncated = true + } + if openAICyberTranscriptItemIsModelGenerated(item) { + hasModelGeneratedItem = true + } + return true + }) + if lookupKeysRotated { + ordered := make([]string, 0, len(result.lookupKeys)) + ordered = append(ordered, result.lookupKeys[nextLookupKey:]...) + ordered = append(ordered, result.lookupKeys[:nextLookupKey]...) + result.lookupKeys = ordered + } + return result + } + + if messages := root.Get("messages"); messages.Exists() { + return appendSequence(messages) + } + input := root.Get("input") + if input.IsArray() { + return appendSequence(input) + } + if input.Type == gjson.String && strings.TrimSpace(input.String()) != "" { + encoded, _ := json.Marshal(input.String()) + _, _ = h.Write([]byte("|item=")) + _, _ = h.Write(encoded) + return openAICyberTranscriptBlockKeys{lookupKeys: []string{hex.EncodeToString(h.Sum(nil))}} + } + return openAICyberTranscriptBlockKeys{} +} + +func openAICyberTranscriptItemStartsUserTurn(item gjson.Result) bool { + if strings.EqualFold(strings.TrimSpace(item.Get("role").String()), "user") { + content := item.Get("content") + if content.IsArray() { + hasUserContent := false + content.ForEach(func(_, block gjson.Result) bool { + switch strings.ToLower(strings.TrimSpace(block.Get("type").String())) { + case "tool_result", "function_call_output", "custom_tool_call_output", "computer_call_output": + default: + hasUserContent = true + } + return !hasUserContent + }) + return hasUserContent + } + return content.Exists() + } + return strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "input_text") +} + +func openAICyberTranscriptItemIsModelGenerated(item gjson.Result) bool { + switch strings.ToLower(strings.TrimSpace(item.Get("role").String())) { + case "assistant", "model": + return true + } + switch strings.ToLower(strings.TrimSpace(item.Get("type").String())) { + case "output_text", "function_call", "tool_call", "custom_tool_call", "computer_call": + return true + default: + return false + } +} diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index c0c5900297..87b1904923 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -34,6 +34,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + SetOpsUpstreamModel(c, upstreamModel) upstreamBody := body if upstreamModel != originalModel { upstreamBody = ReplaceModelInBody(body, upstreamModel) @@ -46,11 +47,13 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( zap.String("upstream_model", upstreamModel), ) - apiKey := account.GetOpenAIApiKey() + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return nil, fmt.Errorf("account %d missing api_key", account.ID) } - baseURL := account.GetOpenAIBaseURL() + // 协议感知:Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点, + // embeddings 需使用 OpenAI 格式 base。 + baseURL := account.GetOpenAIFormatBaseURL() if baseURL == "" { baseURL = "https://api.openai.com" } @@ -89,7 +92,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") @@ -133,11 +136,14 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( Detail: upstreamDetail, }) shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode) + if account.IsOpenAIOAuth() && resp.StatusCode == http.StatusTooManyRequests { + return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMsg, shouldDisable, retryableOnSameAccount) } + if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMsg, respBody) { + return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, retryableOnSameAccount) + } + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount} } writeOpenAIEmbeddingsUpstreamResponse(c, resp, respBody, s.responseHeaderFilter) return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode) diff --git a/backend/internal/service/openai_embeddings_test.go b/backend/internal/service/openai_embeddings_test.go index c7e89d6449..1e79dae5dd 100644 --- a/backend/internal/service/openai_embeddings_test.go +++ b/backend/internal/service/openai_embeddings_test.go @@ -104,3 +104,85 @@ func TestForwardEmbeddings_APIKeyPassthroughRecordsUsageAndBatchInput(t *testing require.Equal(t, "float", gjson.GetBytes(upstream.lastBody, "encoding_format").String()) require.Equal(t, int64(256), gjson.GetBytes(upstream.lastBody, "dimensions").Int()) } + +func TestForwardEmbeddings_AccessStateUsesTypedFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + reqBody := []byte(`{"model":"text-embedding-3-small","input":"hello"}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", bytes.NewReader(reqBody)) + + upstreamBody := []byte(`{"error":{"code":"deactivated_workspace","message":"Workspace is deactivated"}}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_embeddings_access_state"}, + }, + Body: io.NopCloser(bytes.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 43, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + } + + result, err := svc.ForwardEmbeddings(context.Background(), c, account, reqBody, "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusForbidden, failoverErr.StatusCode) + require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage) + require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope) + require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason) + require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction) + require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode) + require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage) + require.False(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, "req_embeddings_access_state", failoverErr.ResponseHeaders.Get("x-request-id")) + require.False(t, c.Writer.Written()) +} + +func TestForwardEmbeddings_NonAccessFailoverKeepsLegacyShape(t *testing.T) { + gin.SetMode(gin.TestMode) + + reqBody := []byte(`{"model":"text-embedding-3-small","input":"hello"}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", bytes.NewReader(reqBody)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 44, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + } + + result, err := svc.ForwardEmbeddings(context.Background(), c, account, reqBody, "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.Empty(t, failoverErr.Stage) + require.Empty(t, failoverErr.Scope) + require.Empty(t, failoverErr.Reason) + require.Zero(t, failoverErr.ClientStatusCode) + require.Empty(t, failoverErr.ClientMessage) + require.Nil(t, failoverErr.ResponseHeaders) + require.False(t, c.Writer.Written()) +} diff --git a/backend/internal/service/openai_failover_cached_body_test.go b/backend/internal/service/openai_failover_cached_body_test.go index 709032d27e..1e1cd0e9d1 100644 --- a/backend/internal/service/openai_failover_cached_body_test.go +++ b/backend/internal/service/openai_failover_cached_body_test.go @@ -127,7 +127,8 @@ func TestOpenAIGatewayService_HandleFailoverSideEffects_DoesNotRereadResponseBod svc.handleFailoverSideEffects(context.Background(), resp, account, []byte(`{"error":{"type":"rate_limit_error","message":"rate limited"}}`)) }) - require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) } func TestGetOpenAIRequestBodyMap_IgnoresLegacyContextCache(t *testing.T) { diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index f624a0080b..3f3883e278 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -348,6 +348,23 @@ func TestPolicyEnforcingFrameConn_FollowupFrameWithoutModelUsesCapturedModel(t * require.Equal(t, "response.create", gjson.GetBytes(payload, "type").String()) } +func TestOpenAIWSPassthroughPolicyModelDoesNotApplyAccountMapping(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{"public-model": "private-model"}, + }, + Extra: map[string]any{"openai_passthrough": true}, + } + + responseCreate := []byte(`{"type":"response.create","model":"public-model"}`) + require.Equal(t, "public-model", openAIWSPassthroughPolicyModelForFrame(account, responseCreate)) + + sessionUpdate := []byte(`{"type":"session.update","session":{"model":"public-model"}}`) + require.Equal(t, "public-model", openAIWSPassthroughPolicyModelFromSessionFrame(account, sessionUpdate)) +} + // TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses pins the // inverse: when the wrapper has NO capturedSessionModel fallback (model is // empty per-frame and no fallback is wired up), the policy fails to match diff --git a/backend/internal/service/openai_fast_service_tier_test.go b/backend/internal/service/openai_fast_service_tier_test.go new file mode 100644 index 0000000000..57e1b59687 --- /dev/null +++ b/backend/internal/service/openai_fast_service_tier_test.go @@ -0,0 +1,841 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// --------------------------------------------------------------------------- +// 请求侧:service_tier 校验(fast/priority 等价、非法值拒绝、省略保持现状) +// --------------------------------------------------------------------------- + +func TestValidateOpenAIServiceTierField(t *testing.T) { + t.Parallel() + + t.Run("fast normalizes to priority", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"fast"}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("priority passes through", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"priority"}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("case and whitespace insensitive", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":" FAST "}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("official tiers pass through", func(t *testing.T) { + for _, tier := range []string{"flex", "auto", "default", "scale"} { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`)) + require.NoError(t, err, "tier %q must be accepted", tier) + require.Equal(t, tier, norm) + } + }) + + t.Run("invalid tier rejected", func(t *testing.T) { + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"turbo"}`)) + require.Error(t, err) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, "turbo", invalid.Value) + require.Contains(t, err.Error(), "invalid service_tier") + require.Contains(t, err.Error(), "fast", "allowed-value hint must mention fast") + }) + + t.Run("omitted field stays valid", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","input":"hi"}`)) + require.NoError(t, err) + require.Empty(t, norm) + }) + + t.Run("null value keeps omission semantics", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":null}`)) + require.NoError(t, err) + require.Empty(t, norm) + }) + + t.Run("explicit empty string rejected as invalid enum value", func(t *testing.T) { + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":""}`)) + require.Error(t, err) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + }) + + t.Run("non-string service_tier rejected", func(t *testing.T) { + // service_tier 必须为字符串;数字/布尔/对象/数组等类型同样按非法值拒绝。 + for _, raw := range []string{ + `{"model":"gpt-5.5","service_tier":123}`, + `{"model":"gpt-5.5","service_tier":true}`, + `{"model":"gpt-5.5","service_tier":{}}`, + `{"model":"gpt-5.5","service_tier":["priority"]}`, + } { + _, err := ValidateOpenAIServiceTierField([]byte(raw)) + require.Error(t, err, "raw=%s must be rejected", raw) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid), "raw=%s", raw) + require.Equal(t, "", invalid.Value, "raw=%s", raw) + require.Contains(t, err.Error(), "invalid service_tier") + } + }) + + t.Run("oversized unknown string is truncated", func(t *testing.T) { + blob := strings.Repeat("z", 4096) + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + blob + `"}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid service_tier") + require.NotContains(t, err.Error(), blob) + require.Less(t, len(err.Error()), 200) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, strings.Repeat("z", 64)+"...", invalid.Value) + }) + + t.Run("non-string large object/array is not echoed", func(t *testing.T) { + blob := strings.Repeat("x", 4096) + payloads := []string{ + `{"model":"gpt-5.5","service_tier":{"blob":"` + blob + `"}}`, + `{"model":"gpt-5.5","service_tier":["` + blob + `"]}`, + } + for _, raw := range payloads { + _, err := ValidateOpenAIServiceTierField([]byte(raw)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid service_tier") + require.NotContains(t, err.Error(), blob) + require.Less(t, len(err.Error()), 200) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, "", invalid.Value) + } + }) +} + +// --------------------------------------------------------------------------- +// 计费:gpt-5.6 系列 / gpt-5.4 按标准价 2x,gpt-5.5 按标准价 2.5x +// --------------------------------------------------------------------------- + +func TestApplyModelSpecificPricingPolicy_EnforcesOpenAIFastRatios(t *testing.T) { + t.Parallel() + + svc := &BillingService{} + + t.Run("gpt-5.5 catalog 2x priority is corrected to 2.5x", func(t *testing.T) { + // 模拟本地 LiteLLM 目录仍携带官方旧口径(gpt-5.5 priority = 2x)。 + catalog := &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + got := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog) + require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12) + // 标准价不被改动。 + require.InDelta(t, 5e-6, got.InputPricePerToken, 1e-12) + // 原始指针不被污染。 + require.InDelta(t, 10e-6, catalog.InputPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.4 keeps 2x", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.4", &ModelPricing{ + InputPricePerToken: 2.5e-6, + InputPricePerTokenPriority: 5e-6, + OutputPricePerToken: 15e-6, + OutputPricePerTokenPriority: 30e-6, + }) + require.InDelta(t, 5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 30e-6, got.OutputPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.6 family keeps 2x", func(t *testing.T) { + for _, model := range []string{"gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-max", "gpt-5.6-sol-preview"} { + got := svc.applyModelSpecificPricingPolicy(model, &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + }) + require.InDelta(t, 10e-6, got.InputPricePerTokenPriority, 1e-12, "model %s", model) + require.InDelta(t, 60e-6, got.OutputPricePerTokenPriority, 1e-12, "model %s", model) + } + }) + + t.Run("missing priority prices are backfilled from standard", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.5", &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + CacheReadPricePerToken: 0.5e-6, + CacheCreationPricePerToken: 5e-6, + }) + require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12) + require.InDelta(t, 12.5e-6, got.CacheCreationPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.5-pro has no mandated fast tier", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.5-pro", &ModelPricing{ + InputPricePerToken: 30e-6, + InputPricePerTokenPriority: 60e-6, + OutputPricePerToken: 180e-6, + }) + require.InDelta(t, 60e-6, got.InputPricePerTokenPriority, 1e-12) + }) + + t.Run("unrelated models untouched", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("claude-opus-5", &ModelPricing{InputPricePerToken: 1, OutputPricePerToken: 2}) + require.InDelta(t, 1, got.InputPricePerToken, 1e-12) + require.Zero(t, got.InputPricePerTokenPriority) + }) +} + +func TestOpenAIFastBillingMultiplier_2xAnd25x(t *testing.T) { + t.Parallel() + + // 目录数据携带官方旧口径(gpt-5.5 priority=2x);修正后 fast 必须按 2.5x 计费。 + catalog := map[string]*LiteLLMModelPricing{ + "gpt-5.4": { + InputCostPerToken: 2.5e-6, + InputCostPerTokenPriority: 5e-6, + OutputCostPerToken: 15e-6, + OutputCostPerTokenPriority: 30e-6, + CacheReadInputTokenCost: 0.25e-6, + CacheReadInputTokenCostPriority: 0.5e-6, + }, + "gpt-5.5": { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + "gpt-5.6-sol": { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + } + billing := NewBillingService(&config.Config{}, &PricingService{pricingData: catalog}) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000} + + standard := func(model string) *CostBreakdown { + cost, err := billing.CalculateCost(model, tokens, 1) + require.NoError(t, err) + return cost + } + fast := func(model, tier string) *CostBreakdown { + cost, err := billing.CalculateCostWithServiceTier(model, tokens, 1, tier) + require.NoError(t, err) + return cost + } + + tests := []struct { + model string + ratio float64 + }{ + {model: "gpt-5.4", ratio: 2.0}, + {model: "gpt-5.5", ratio: 2.5}, + {model: "gpt-5.6-sol", ratio: 2.0}, + {model: "gpt-5.6-terra", ratio: 2.0}, + {model: "gpt-5.6-luna", ratio: 2.0}, + } + for _, tt := range tests { + t.Run(tt.model+"/fast", func(t *testing.T) { + base := standard(tt.model) + fastCost := fast(tt.model, "fast") + require.InDelta(t, base.TotalCost*tt.ratio, fastCost.TotalCost, 1e-9, + "fast total must be %.1fx standard", tt.ratio) + }) + t.Run(tt.model+"/priority_alias", func(t *testing.T) { + fastCost := fast(tt.model, "fast") + priorityCost := fast(tt.model, "priority") + require.InDelta(t, fastCost.TotalCost, priorityCost.TotalCost, 1e-12, + "client alias fast must bill identically to priority") + require.InDelta(t, standard(tt.model).TotalCost*tt.ratio, priorityCost.TotalCost, 1e-9) + }) + t.Run(tt.model+"/no_tier_unchanged", func(t *testing.T) { + base := standard(tt.model) + noTier, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "") + require.NoError(t, err) + require.InDelta(t, base.TotalCost, noTier.TotalCost, 1e-12) + }) + t.Run(tt.model+"/default_equals_standard", func(t *testing.T) { + base := standard(tt.model) + defaultCost, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "default") + require.NoError(t, err) + require.InDelta(t, base.TotalCost, defaultCost.TotalCost, 1e-12) + require.InDelta(t, base.InputCost, defaultCost.InputCost, 1e-12) + require.InDelta(t, base.OutputCost, defaultCost.OutputCost, 1e-12) + require.InDelta(t, base.CacheReadCost, defaultCost.CacheReadCost, 1e-12) + }) + } +} + +func TestOpenAIFastBilling_FastMultiplierOverridesEnforcedRatio(t *testing.T) { + t.Parallel() + + svc := &BillingService{} + catalog := &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + pricing := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog) + require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12, "enforce must still write 2.5x priority prices") + require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12) + + multiplier := 1.7 + pricing.FastMultiplier = &multiplier + + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000} + standard := svc.computeTokenBreakdown(pricing, tokens, 1, "", false) + fast := svc.computeTokenBreakdown(pricing, tokens, 1, "fast", false) + priority := svc.computeTokenBreakdown(pricing, tokens, 1, "priority", false) + + require.InDelta(t, standard.TotalCost*1.7, fast.TotalCost, 1e-9) + require.InDelta(t, fast.TotalCost, priority.TotalCost, 1e-12) + + withoutOverride := *pricing + withoutOverride.FastMultiplier = nil + enforced := svc.computeTokenBreakdown(&withoutOverride, tokens, 1, "fast", false) + require.InDelta(t, standard.TotalCost*2.5, enforced.TotalCost, 1e-9, + "without FastMultiplier the same enforced prices still bill 2.5x") +} + +// --------------------------------------------------------------------------- +// 上游 payload:fast 归一化为 priority 并确实到达上游 +// --------------------------------------------------------------------------- + +func TestForwardAsChatCompletions_ServiceTierFastNormalizedToPriorityUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 21, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + } + + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.Error(t, err) // upstream 400 → 错误返回,但请求体已被 recorder 捕获 + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(), + "client alias fast must reach upstream as priority") +} + +func TestForwardAsChatCompletions_ServiceTierPriorityPreservedUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"priority","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st2"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 2, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + } + + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.Error(t, err) + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) +} + +func TestForward_ResponsesServiceTierFastNormalizedToPriorityUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(), + "client alias fast must reach the upstream as priority") + // 计费上下文:result 携带归一化后的 tier。 + require.NotNil(t, result.ServiceTier) + require.Equal(t, "priority", *result.ServiceTier) +} + +func TestForward_ResponsesServiceTierOmittedStaysOmitted(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st2"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_2","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastBody) + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(), + "omitted service_tier must stay omitted") + require.Nil(t, result.ServiceTier) +} + +// --------------------------------------------------------------------------- +// 流式计费上下文:service_tier 需要从请求体传到 usage 计费 +// --------------------------------------------------------------------------- + +func TestForwardStreaming_ServiceTierPropagatedToResult(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-stream-st"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "priority", *result.ServiceTier, "streaming billing context must carry the normalized tier") + // /v1/responses 流是上游 SSE 原样透传:上游没回 service_tier 就不该出现; + // 网关只在计费结果里携带请求侧 tier,不往下游流里注入。 + require.Contains(t, rec.Body.String(), `"delta":"hi"`, "streamed content must reach the client") + require.NotContains(t, rec.Body.String(), `"service_tier"`, "upstream did not return service_tier, client stream must stay untouched") +} + +// --------------------------------------------------------------------------- +// 上游回显优先:请求 fast 但上游真实返回 default → 计费按标准价 +// --------------------------------------------------------------------------- + +func TestForward_ResponsesUpstreamEchoesDefault_OverridesRequestFast(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 上游回显 service_tier=default(例如请求实际被降级)。 + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-echo"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","service_tier":"default","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "upstream-echoed default must override the client-requested fast tier for billing") + // 非流式响应原样透传:客户端同样看到 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) + require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`) +} + +func TestForwardStreaming_UpstreamEchoesDefault_OverridesRequestFast(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-echo-stream"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "terminal SSE event's upstream-echoed default must win for billing") + // 流式原样透传:客户端在终止事件里看到 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) +} + +func TestForwardAsChatCompletions_UpstreamEchoesDefault_BillsStandard(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-chat-echo"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 21, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "CC bridge must bill on the upstream-echoed default, not the requested fast") + // 缓冲转回 Chat Completions:客户端响应里如实回显 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) + require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`) +} + +// --------------------------------------------------------------------------- +// policy filter:删除 service_tier 后不得再按原请求 Fast 计费 +// --------------------------------------------------------------------------- + +func TestForward_ServiceTierFilteredByPolicyBillsStandard(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"priority","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 管理员配置 priority → filter:字段在出站前被删除。 + settings := &OpenAIFastPolicySettings{Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }}} + raw, err := json.Marshal(settings) + require.NoError(t, err) + repo := &openAIFastPolicyRepoStub{values: map[string]string{SettingKeyOpenAIFastPolicySettings: string(raw)}} + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-filter"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(repo, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + // 出站 body 已剥离 service_tier、上游也未回显 → 无 tier → 按标准价计费。 + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(), + "policy filter must strip service_tier from the outbound body") + require.Nil(t, result.ServiceTier, "filtered request must not bill as fast") +} + +// --------------------------------------------------------------------------- +// 上游回显观察与解析器单测 +// --------------------------------------------------------------------------- + +func TestUpstreamResponseModelObserver_ObservesServiceTier(t *testing.T) { + t.Parallel() + + observer := &upstreamResponseModelObserver{} + // 上游约束:非终止且有类型的事件(response.created)回显的是请求档位而非 + // 实际处理档位,忽略。 + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5","service_tier":"flex"}}`), "response.created") + require.Empty(t, observer.ServiceTier()) + + // terminal 声明(带 model 帧)优先。 + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + require.Equal(t, "default", observer.ServiceTier()) + + // Chat Completions 顶层 service_tier 按 untyped payload 观察(无 type 字段)。 + ccObserver := &upstreamResponseModelObserver{} + ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "") + require.Equal(t, "priority", ccObserver.ServiceTier()) + + // 无 model 的帧不触发 tier 观察(上游约束:tier 声明必带 model)。 + modelFree := &upstreamResponseModelObserver{} + modelFree.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + require.Empty(t, modelFree.ServiceTier()) +} + +func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { + t.Parallel() + + priority := func() *string { v := "priority"; return &v }() + + t.Run("upstream echo wins over outbound tier", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTier(c, priority) + require.NotNil(t, got) + require.Equal(t, "default", *got) + }) + + t.Run("no upstream echo falls back to outbound tier", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + beginUpstreamResponseModelObservation(c) + + got := resolvedOpenAIUpstreamServiceTier(c, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("upstream alias fast normalizes to priority", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"fast"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTier(c, nil) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("no observer keeps outbound tier", func(t *testing.T) { + got := resolvedOpenAIUpstreamServiceTier(nil, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("no observer and no outbound tier stays nil", func(t *testing.T) { + require.Nil(t, resolvedOpenAIUpstreamServiceTier(nil, nil)) + }) + + t.Run("local observer wins without gin context", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTierFromObserver(observer, priority) + require.NotNil(t, got) + require.Equal(t, "default", *got) + }) + + t.Run("nil local observer falls back to outbound tier", func(t *testing.T) { + got := resolvedOpenAIUpstreamServiceTierFromObserver(nil, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) +} diff --git a/backend/internal/service/openai_first_output_timeout_test.go b/backend/internal/service/openai_first_output_timeout_test.go index 799f7af12b..6783de90d1 100644 --- a/backend/internal/service/openai_first_output_timeout_test.go +++ b/backend/internal/service/openai_first_output_timeout_test.go @@ -542,7 +542,7 @@ func TestOpenAINativeFirstOutputScannerAllowsLargeEventAfterSemanticBoundary(t * require.Equal(t, "request-large-image", rec.Result().Header.Get("X-Request-Id")) } -func TestOpenAINativeFirstOutputTimeoutDisabledPreservesKeepaliveFlush(t *testing.T) { +func TestOpenAINativeFirstOutputTimeoutDisabledKeepsPreamblePrivateAcrossKeepalive(t *testing.T) { svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ StreamKeepaliveInterval: 1, MaxLineSize: defaultMaxLineSize, @@ -552,7 +552,7 @@ func TestOpenAINativeFirstOutputTimeoutDisabledPreservesKeepaliveFlush(t *testin defer func() { _ = pw.Close() }() _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_stalled\"}}\n\n")) _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_stalled\"}}\n\n")) - time.Sleep(1100 * time.Millisecond) + time.Sleep(2100 * time.Millisecond) }() rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -561,10 +561,11 @@ func TestOpenAINativeFirstOutputTimeoutDisabledPreservesKeepaliveFlush(t *testin _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model") - require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) require.Contains(t, rec.Body.String(), ":\n\n") - require.Contains(t, rec.Body.String(), "response.created") - require.Contains(t, rec.Body.String(), "response.in_progress") + require.NotContains(t, rec.Body.String(), "response.created") + require.NotContains(t, rec.Body.String(), "response.in_progress") } func TestOpenAINativeFirstOutputFailoverKeepsAttemptHeadersPrivateAfterKeepaliveCommit(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump.go b/backend/internal/service/openai_gateway_anthropic_native_pump.go new file mode 100644 index 0000000000..8a19677705 --- /dev/null +++ b/backend/internal/service/openai_gateway_anthropic_native_pump.go @@ -0,0 +1,126 @@ +package service + +// 国产供应商 Anthropic 协议转换路径的上游 SSE 行泵。 +// +// 这两条转换链(CC×anthropic / Responses×anthropic)的上游 ctx 是 +// WithoutCancel(detachStreamUpstreamContext)、http.Client 无整体 Timeout, +// 客户端断开后的排水阶段若上游挂住 SSE(不发数据也不断连), +// scanner.Scan() 将永久阻塞:goroutine 钉死、resp.Body 不归还、连接池位 +// 被占用、usage 永不落库。 +// +// 与 handleAnthropicStreamingResponse / readOpenAICompatBufferedTerminal 的 +// 同类排水一致,本泵用 gateway.stream_data_interval_timeout(默认 180s)作为 +// 逐行读间隔上限,超时即向调用方返回 errAnthropicNativeStreamIdle,由调用方 +// 关闭 resp.Body 解除阻塞的读并结束排水。 + +import ( + "bufio" + "errors" + "io" + "time" +) + +// errAnthropicNativeStreamIdle 表示上游流读间隔超时(见上方文件注释)。 +var errAnthropicNativeStreamIdle = errors.New("stream data interval timeout") + +// anthropicNativeLineEvent 是行泵交付的单次读取结果:line 为一行 SSE 文本, +// err 为 scanner 读错误(流自然结束时 next 返回 io.EOF,不经过本字段)。 +type anthropicNativeLineEvent struct { + line string + err error +} + +// anthropicNativeLinePump 以独立 goroutine 泵送 scanner 的行,并对逐行到达 +// 间隔施加 interval 上限(<=0 表示禁用,保持无界读的旧行为)。 +type anthropicNativeLinePump struct { + events chan anthropicNativeLineEvent + done chan struct{} + timer *time.Timer + interval time.Duration +} + +// newAnthropicNativeLinePump 启动泵 goroutine;调用方 defer pump.stop()。 +func newAnthropicNativeLinePump(scanner *bufio.Scanner, interval time.Duration) *anthropicNativeLinePump { + p := &anthropicNativeLinePump{ + events: make(chan anthropicNativeLineEvent, 16), + done: make(chan struct{}), + interval: interval, + } + if interval > 0 { + p.timer = time.NewTimer(interval) + } + go func() { + defer close(p.events) + for scanner.Scan() { + select { + case p.events <- anthropicNativeLineEvent{line: scanner.Text()}: + case <-p.done: + return + } + } + if err := scanner.Err(); err != nil { + select { + case p.events <- anthropicNativeLineEvent{err: err}: + case <-p.done: + } + } + }() + return p +} + +// next 阻塞返回下一行。返回 io.EOF 表示上游正常收流;errAnthropicNativeStreamIdle +// 表示 interval 内无任何数据到达(计时从收到上一行时起算,事件处理耗时不算入, +// 与 readOpenAICompatBufferedTerminal 的 resetTimeout 语义一致)。 +func (p *anthropicNativeLinePump) next() (string, error) { + var timeoutCh <-chan time.Time + if p.timer != nil { + timeoutCh = p.timer.C + } + select { + case ev, ok := <-p.events: + if !ok { + return "", io.EOF + } + p.resetTimer() + return ev.line, ev.err + case <-timeoutCh: + return "", errAnthropicNativeStreamIdle + } +} + +// resetTimer 在收到一行后重启间隔计时器。 +func (p *anthropicNativeLinePump) resetTimer() { + if p.timer == nil { + return + } + if !p.timer.Stop() { + select { + case <-p.timer.C: + default: + } + } + p.timer.Reset(p.interval) +} + +// stop 终止泵 goroutine。注意:goroutine 若正阻塞在 scanner.Read 上,需由 +// 调用方关闭 resp.Body(间隔超时分支已做)才能真正退出。 +func (p *anthropicNativeLinePump) stop() { + close(p.done) + if p.timer != nil { + if !p.timer.Stop() { + select { + case <-p.timer.C: + default: + } + } + } +} + +// anthropicNativeStreamInterval 返回本组转换路径适用的读间隔上限; +// gateway.stream_data_interval_timeout <= 0 时视为禁用。 +func (s *OpenAIGatewayService) anthropicNativeStreamInterval() time.Duration { + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + return time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + return 0 +} diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go new file mode 100644 index 0000000000..1e9e1b39a6 --- /dev/null +++ b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go @@ -0,0 +1,244 @@ +package service + +// 国产供应商 Anthropic 协议转换路径的上游读间隔超时回归测试(B3): +// 上游挂住 SSE(不发数据也不断连)时,CC×anthropic / Responses×anthropic +// 的读循环必须按 gateway.stream_data_interval_timeout 结束,而不是永久阻塞。 + +import ( + "bufio" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" +) + +func newNativeAnthropicHangTestService(intervalSec int) *OpenAIGatewayService { + return &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: intervalSec, + MaxLineSize: defaultMaxLineSize, + }, + }, + } +} + +func newHangingUpstreamResponse() (*http.Response, *io.PipeReader, *io.PipeWriter) { + pr, pw := io.Pipe() + return &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}, pr, pw +} + +// miniAnthropicSSEStream 是一段最小可转换的 Anthropic 事件流。 +func miniAnthropicSSEStream() string { + return strings.Join([]string{ + "event: message_start", + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":1}}}`, + "", + "event: content_block_start", + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`, + "", + "event: content_block_stop", + `data: {"type":"content_block_stop","index":0}`, + "", + "event: message_delta", + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + "", + "event: message_stop", + `data: {"type":"message_stop"}`, + "", + "", + }, "\n") +} + +func TestAnthropicNativeLinePump_TimesOutWithoutData(t *testing.T) { + pr, _ := io.Pipe() + scanner := bufio.NewScanner(pr) + defer func() { _ = pr.Close() }() + + pump := newAnthropicNativeLinePump(scanner, 50*time.Millisecond) + defer pump.stop() + + start := time.Now() + _, err := pump.next() + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected interval timeout, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout not respected: %v", elapsed) + } +} + +func TestAnthropicNativeLinePump_DataResetsTimer(t *testing.T) { + pr, pw := io.Pipe() + scanner := bufio.NewScanner(pr) + pump := newAnthropicNativeLinePump(scanner, 1*time.Second) + defer pump.stop() + + go func() { + _, _ = pw.Write([]byte("event: ping\n")) + // 保持流打开且不再发数据:第二次 next 必须超时。 + time.Sleep(3 * time.Second) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + line, err := pump.next() + if err != nil || line != "event: ping" { + t.Fatalf("expected first line, got %q err=%v", line, err) + } + + start := time.Now() + _, err = pump.next() + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected interval timeout after data stops, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout not respected: %v", elapsed) + } +} + +func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(1) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + start := time.Now() + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true) + _ = pw.Close() + _ = pr.Close() + + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected stream timeout error, got %v", err) + } + if res == nil { + t.Fatalf("expected result carrying accumulated usage") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("handler did not respect interval bound: %v", elapsed) + } +} + +func TestCCBufferedFromNativeAnthropic_HangTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(1) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + start := time.Now() + _, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start) + _ = pw.Close() + _ = pr.Close() + + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected stream timeout error, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("handler did not respect interval bound: %v", elapsed) + } + if !strings.Contains(rec.Body.String(), "Upstream stream data interval timeout") { + t.Fatalf("expected 502 error body, got %q", rec.Body.String()) + } +} + +func TestResponsesStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(1) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + start := time.Now() + res, err := svc.handleResponsesStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, apicompat.ResponsesClientToolMapping{}) + _ = pw.Close() + _ = pr.Close() + + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected stream timeout error, got %v", err) + } + if res == nil { + t.Fatalf("expected result carrying accumulated usage") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("handler did not respect interval bound: %v", elapsed) + } +} + +func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + go func() { + _, _ = pw.Write([]byte(miniAnthropicSSEStream())) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatalf("expected result") + } + body := rec.Body.String() + if !strings.Contains(body, "Hello") { + t.Fatalf("expected converted text chunk, got %q", body) + } + if !strings.Contains(body, "data: [DONE]") { + t.Fatalf("expected [DONE] terminator, got %q", body) + } +} + +func TestCCBufferedFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + go func() { + _, _ = pw.Write([]byte(miniAnthropicSSEStream())) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + res, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatalf("expected result") + } + body := rec.Body.String() + if !strings.Contains(body, "Hello") { + t.Fatalf("expected converted text in buffered response, got %q", body) + } + if res.Usage.InputTokens != 10 || res.Usage.OutputTokens != 5 { + t.Fatalf("expected usage 10/5, got %+v", res.Usage) + } +} diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go index 80865e2891..d5be3cec49 100644 --- a/backend/internal/service/openai_gateway_apikey_item_id_test.go +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -39,8 +39,12 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes {"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"}, {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]}, {"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"}, + {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"}, + {"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"}, + {"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}}, + {"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}}, {"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"}, - {"type":"web_search_call","id":"item_unconstrained"} + {"type":"web_search_call","id":"item_wrong_web"} ] }`) @@ -58,9 +62,100 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String()) require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String()) require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String()) - require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.4.id").String()) - require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.4.call_id").String()) - require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.5.id").String()) + require.False(t, gjson.GetBytes(forwarded, "input.4.id").Exists()) + require.Equal(t, "ctc_valid", gjson.GetBytes(forwarded, "input.5.id").String()) + require.False(t, gjson.GetBytes(forwarded, "input.6.id").Exists()) + require.Equal(t, "tsc_valid", gjson.GetBytes(forwarded, "input.7.id").String()) + require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.8.id").String()) + require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.8.call_id").String()) + require.False(t, gjson.GetBytes(forwarded, "input.9.id").Exists()) +} + +func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + t.Run(accountType, func(t *testing.T) { + upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Type = accountType + account.Credentials = map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-account", + } + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":true, + "instructions":"test", + "input":[ + {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"}, + {"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"}, + {"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}}, + {"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}} + ] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists()) + require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists()) + require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String()) + }) + } +} + +func TestOpenAIGatewayService_SetupTokenLegacy_SanitizesAndTransforms(t *testing.T) { + gin.SetMode(gin.TestMode) + upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Type = AccountTypeSetupToken + account.Credentials = map[string]any{ + "access_token": "setup-token", + "chatgpt_account_id": "chatgpt-account", + } + account.Extra = map[string]any{"openai_passthrough": false} + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":true, + "store":true, + "reasoning":{"mode":"pro"}, + "instructions":"test", + "input":[ + {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"}, + {"type":"function_call_output","call_id":"call_orphan","output":"orphan"} + ] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String()) + require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning.mode").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "input").Array(), 1) } // TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs @@ -115,12 +210,20 @@ func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) { }{ {"reasoning item_* id", "reasoning", "item_bad_reasoning", true}, {"reasoning rs id", "reasoning", "rs_abc123", false}, - {"reasoning empty id", "reasoning", "", false}, + {"reasoning empty id", "reasoning", "", true}, {"message msg id", "message", "msg_abc", false}, {"message item id", "message", "item_x", true}, {"function_call fc id", "function_call", "fc_abc", false}, + {"function_call ctc id", "function_call", "ctc_abc", true}, {"function_call item id", "function_call", "item_x", true}, - {"unconstrained type", "web_search_call", "ws_001", false}, + {"custom tool ctc id", "custom_tool_call", "ctc_abc", false}, + {"custom tool fc id", "custom_tool_call", "fc_abc", true}, + {"tool search tsc id", "tool_search_call", "tsc_abc", false}, + {"tool search fc id", "tool_search_call", "fc_abc", true}, + {"web search ws id", "web_search_call", "ws_001", false}, + {"web search item id", "web_search_call", "item_001", true}, + {"custom output fc id", "custom_tool_call_output", "fc_001", false}, + {"custom output ctco id", "custom_tool_call_output", "ctco_001", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -154,3 +257,38 @@ func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing require.Less(t, largeAllocated, smallAllocated*30, "10x more input items must not cause quadratic whole-body allocation growth") } + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueReferences(t *testing.T) { + body := []byte(`{"type":"response.create","input":[ + {"type":"custom_tool_call","id":"ctc_call","call_id":"call_custom","name":"apply_patch","input":"patch"}, + {"type":"custom_tool_call_output","id":"ctco_bad","call_id":"call_custom","output":"done"}, + {"type":"item_reference","id":"ctco_bad"}, + {"type":"future_item","id":"item_future","payload":"keep"} + ]}`) + + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + t.Run(accountType, func(t *testing.T) { + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ + Platform: PlatformOpenAI, + Type: accountType, + }, false) + + require.NoError(t, err) + require.True(t, changed) + require.Len(t, gjson.GetBytes(normalized, "input").Array(), 4) + require.Equal(t, "ctc_call", gjson.GetBytes(normalized, "input.0.id").String()) + require.Equal(t, "call_custom", gjson.GetBytes(normalized, "input.1.call_id").String()) + require.False(t, gjson.GetBytes(normalized, "input.1.id").Exists()) + require.Equal(t, "ctco_bad", gjson.GetBytes(normalized, "input.2.id").String()) + require.Equal(t, "item_future", gjson.GetBytes(normalized, "input.3.id").String()) + + second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{ + Platform: PlatformOpenAI, + Type: accountType, + }, false) + require.NoError(t, err) + require.False(t, changedAgain) + require.JSONEq(t, string(normalized), string(second)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 8080d49e5c..a22288b70a 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -125,11 +125,13 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( if account.Platform != PlatformGrok && !tempUnscheduled { shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) } - return newOpenAIUpstreamFailoverError( + return s.newOpenAIAccountFailoverError( + account, resp.StatusCode, resp.Header, respBody, upstreamMsg, + shouldDisable, !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), ) } @@ -150,7 +152,7 @@ func (s *OpenAIGatewayService) openAIChatCompletionsTargetURL(account *Account) // resolveCCFallbackTarget 解析两条 CC 回退路径共用的账号凭证与上游端点 // (回退路径仅面向 APIKey 账号,凭证恒为 openai api_key)。 func (s *OpenAIGatewayService) resolveCCFallbackTarget(account *Account) (apiKey string, targetURL string, err error) { - apiKey = account.GetOpenAIApiKey() + apiKey = strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return "", "", fmt.Errorf("account %d missing api_key", account.ID) } @@ -184,6 +186,10 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) } + // 记录本次实际选择的协议端点,供错误日志和用量日志在没有 + // OpenAIForwardResult(例如 503/传输失败)时使用。每次发送都覆盖, + // 避免 Gin context 在账号 failover 尝试之间残留旧端点。 + SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") upstreamReq = upstreamReq.WithContext(WithHTTPUpstreamProfile(upstreamReq.Context(), HTTPUpstreamProfileOpenAI)) upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Authorization", "Bearer "+bearerToken) @@ -220,7 +226,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -247,6 +253,7 @@ type ccStreamScanState struct { // emit 回调做各自的协议转换与写出。读错误按既有约定过滤 context 取消类噪声后 // 记入 Warn 日志。 func (s *OpenAIGatewayService) scanCCStream( + c *gin.Context, resp *http.Response, logPrefix string, requestID string, @@ -270,6 +277,12 @@ func (s *OpenAIGatewayService) scanCCStream( st.SawDone = true break } + // 观察上游 CC chunk 回显的 model / service_tier(计费以回显为准)。 + // CC chunk 无 type 字段,按 untyped payload 观察(上游约束:只有终止 + // 事件与无类型 body 报告实际处理档位)。 + if observer := upstreamResponseModelObserverFromContext(c); observer != nil { + observer.ObserveOpenAI([]byte(payload), "") + } if u := extractCCStreamUsage(payload); u != nil { st.Usage = *u @@ -329,6 +342,11 @@ func (s *OpenAIGatewayService) readCCUpstreamJSONResponse( writeError(c, http.StatusBadGateway, "api_error", "Failed to parse upstream response") return nil, OpenAIUsage{}, fmt.Errorf("parse chat completions response: %w", err) } + // 观察上游 CC JSON 回显的 model / service_tier(计费以回显为准)。 + // CC JSON 无 type 字段,按 untyped payload 观察(上游约束)。 + if observer := upstreamResponseModelObserverFromContext(c); observer != nil { + observer.ObserveOpenAI(respBody, "") + } usage := OpenAIUsage{} if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { diff --git a/backend/internal/service/openai_gateway_cc_tool_call_identity.go b/backend/internal/service/openai_gateway_cc_tool_call_identity.go new file mode 100644 index 0000000000..6b0249c9c6 --- /dev/null +++ b/backend/internal/service/openai_gateway_cc_tool_call_identity.go @@ -0,0 +1,103 @@ +package service + +import ( + "bytes" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// stripEmptyChatToolCallIdentityFromSSELine 从 CC 流式 SSE 行中剔除 +// choices[*].delta.tool_calls[*] 上的空 id / 空 function.name 字段。 +// +// DashScope/DeepSeek 等 OpenAI 兼容上游会把同一个 tool_calls[index] +// 拆成多个 delta:首个 delta 带合法 id + function.name(arguments 可能 +// 为空),后续参数 delta 带 id:"" 与 function.name:"" 只追加 arguments +// 碎片。dsh 等客户端按 `!== undefined` 合并字段,空串会被当成有效值 +// 覆盖首包合法 id/name,最终得到 {"id":"","name":"",...} 导致 +// ToolNotFoundError: unknown tool ""。这里无状态剔除空串字段(缺失 +// 即不覆盖),不补写、不记忆首包 id/name,适用于所有走 raw CC 直转 +// 路径的账号(不限定 DeepSeek)。 +// +// 只处理流式 chunk 的 delta.tool_calls;非流式 message.tool_calls 不属于 +// 本 helper 范围。 +func stripEmptyChatToolCallIdentityFromSSELine(line string) string { + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + return line + } + trimmed := strings.TrimSpace(payload) + if trimmed == "" || trimmed == "[DONE]" { + return line + } + rewritten, changed := stripEmptyChatToolCallIdentity([]byte(payload)) + if !changed { + return line + } + prefixLen := len(line) - len(payload) + if prefixLen < 0 { + return line + } + return line[:prefixLen] + string(rewritten) +} + +// stripEmptyChatToolCallIdentity 从单个 CC 流式 chunk payload 中删除 +// choices[*].delta.tool_calls[*] 上存在但为空字符串的 id 与 +// function.name 字段;arguments(即使是空串)、index、type 与其它字段 +// 一律保留,非空 id/name 不动。多 choice、多 index 都会处理。 +// +// 返回 (原始 payload, false) 当:payload 为空、不含 "tool_calls"、 +// 非法 JSON、无 choices / delta / tool_calls 数组、或没有需要删除的 +// 字段。sjson 删除失败时 fail-closed 返回原始 payload。 +func stripEmptyChatToolCallIdentity(payload []byte) ([]byte, bool) { + if len(payload) == 0 { + return payload, false + } + // 热路径快速失败:绝大多数 chunk 没有 tool_calls。 + if !bytes.Contains(payload, []byte("tool_calls")) { + return payload, false + } + if !gjson.ValidBytes(payload) { + return payload, false + } + choices := gjson.GetBytes(payload, "choices") + if !choices.Exists() || !choices.IsArray() { + return payload, false + } + updated := payload + changed := false + for ci, choice := range choices.Array() { + delta := choice.Get("delta") + if !delta.Exists() || !delta.IsObject() { + continue + } + toolCalls := delta.Get("tool_calls") + if !toolCalls.Exists() || !toolCalls.IsArray() { + continue + } + for ti, tc := range toolCalls.Array() { + if id := tc.Get("id"); id.Exists() && id.Type == gjson.String && id.Str == "" { + next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".id") + if err != nil { + return payload, false + } + updated = next + changed = true + } + if name := tc.Get("function.name"); name.Exists() && name.Type == gjson.String && name.Str == "" { + next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".function.name") + if err != nil { + return payload, false + } + updated = next + changed = true + } + } + } + if !changed { + return payload, false + } + return updated, true +} diff --git a/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go b/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go new file mode 100644 index 0000000000..aa27c80637 --- /dev/null +++ b/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go @@ -0,0 +1,179 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched 首包带合法 +// id/name 的 delta 必须原样保留(changed=false)。 +func TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched(t *testing.T) { + payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.False(t, changed) + require.Equal(t, string(payload), string(rewritten)) + require.Equal(t, "call_example", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String()) + require.Equal(t, "web_search", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String()) + // 首包 arguments 为空串也不应被删除。 + require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists()) + require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_FollowUpDelta 后续参数 delta 的 +// `"id":""` 与 `"function":{"name":""}` 应被删除;arguments 碎片、 +// index、type 保留。 +func TestStripEmptyChatToolCallIdentity_FollowingDelta(t *testing.T) { + payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.Equal(t, `{"query":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) + require.Equal(t, int64(0), gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.index").Int()) + require.Equal(t, "function", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.type").String()) + // 空串字段不得再出现在 payload 里。 + require.NotContains(t, string(rewritten), `"id":""`) + require.NotContains(t, string(rewritten), `"name":""`) +} + +// TestStripEmptyChatToolCallIdentity_OnlyEmptyName / _OnlyEmptyID 只删 +// 空的那一个,非空字段必须保留。 +func TestStripEmptyChatToolCallIdentity_OnlyEmptyName(t *testing.T) { + payload := []byte(`{"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.Equal(t, "call_1", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_OnlyEmptyID 覆盖 `"id": ""` 带空格的 +// JSON 形式,确认 gjson/sjson 均能识别。 +func TestStripEmptyChatToolCallIdentity_OnlyEmptyID(t *testing.T) { + payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id" : "" , "type" : "function","function":{"name":"get_weather","arguments":"{}"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists()) + require.Equal(t, "get_weather", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String()) + require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept 空 arguments 不删, +// 只有 id/name 空串被剔除。 +func TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept(t *testing.T) { + payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists()) + require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls 两个并行 index 都要 +// 处理:合法的 index 0 保留,后续参数 delta 的 index 1 剔除空 id/name。 +func TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls(t *testing.T) { + payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"tool_a","arguments":"{\"x\":"}},{"index":1,"id":"","type":"function","function":{"name":"","arguments":"{\"y\":"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.Equal(t, "call_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String()) + require.Equal(t, "tool_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String()) + require.Equal(t, `{"x":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.name").Exists()) + require.Equal(t, `{"y":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_Passthrough 无 tool_calls、无 choices、 +// 非数组 tool_calls、非法 JSON 一律原样返回。 +func TestStripEmptyChatToolCallIdentity_Passthrough(t *testing.T) { + tests := []struct { + name string + payload string + }{ + {"no tool_calls", `{"choices":[{"index":0,"delta":{"content":"hi"}}]}`}, + {"no choices", `{"id":"chatcmpl_x"}`}, + {"empty choices", `{"choices":[]}`}, + {"tool_calls not array", `{"choices":[{"index":0,"delta":{"tool_calls":{"foo":1}}}]}`}, + {"invalid JSON", `{"choices":[{`}, + {"empty string", ``}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rewritten, changed := stripEmptyChatToolCallIdentity([]byte(tt.payload)) + require.False(t, changed) + require.Equal(t, tt.payload, string(rewritten)) + }) + } +} + +// TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough SSE 行级:非 +// data 行、[DONE]、空行原样;data 行保留 `data: ` 前缀。 +func TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough(t *testing.T) { + tests := []struct { + name string + line string + }{ + {"done", "data: [DONE]"}, + {"non-data line", ": keep-alive"}, + {"empty line", ""}, + {"comment line", ":"}, + {"content chunk", `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.line, stripEmptyChatToolCallIdentityFromSSELine(tt.line)) + }) + } +} + +// TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix 改写后的 +// SSE 行必须保留 `data: ` 前缀。 +func TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix(t *testing.T) { + line := `data: {"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{}"}}]}}]}` + got := stripEmptyChatToolCallIdentityFromSSELine(line) + require.True(t, strings.HasPrefix(got, "data: ")) + payload, ok := extractOpenAISSEDataLine(got) + require.True(t, ok) + require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.id").Exists()) + require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.Equal(t, "{}", gjson.Get(payload, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_DshClientMerge 模拟 dsh rc.2 adapter 的 +// 合并逻辑(字段存在——含空串——才覆盖):sanitize 后合并,最终 id/name 必须 +// 仍是首包合法值,arguments 为各碎片拼接。 +func TestStripEmptyChatToolCallIdentity_DshClientMerge(t *testing.T) { + lines := []string{ + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`, + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`, + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`, + } + + var mergedID, mergedName, mergedArgs string + for _, line := range lines { + sanitized := stripEmptyChatToolCallIdentityFromSSELine(line) + payload, ok := extractOpenAISSEDataLine(sanitized) + require.True(t, ok) + for _, tc := range gjson.Get(payload, "choices.0.delta.tool_calls").Array() { + if v := tc.Get("id"); v.Exists() { + mergedID = v.String() + } + if v := tc.Get("function.name"); v.Exists() { + mergedName = v.String() + } + if v := tc.Get("function.arguments"); v.Exists() { + mergedArgs += v.String() + } + } + } + + require.Equal(t, "call_example", mergedID) + require.Equal(t, "web_search", mergedName) + require.Equal(t, `{"query":"example"}`, mergedArgs) +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 87973304c0..a3a992175c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -1,6 +1,7 @@ package service import ( + "bufio" "context" "encoding/json" "errors" @@ -45,11 +46,11 @@ var cursorResponsesUnsupportedFields = []string{ // 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂: // 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。 // -// 当前路由策略(基于账号覆盖模式/探测标记,详见 openai_compat.ShouldUseResponsesAPI): -// - APIKey 账号 + 强制或探测确认不支持 Responses → 走 forwardAsRawChatCompletions -// 直转上游 /v1/chat/completions,不做协议转换 -// - 其他所有情况(OAuth、APIKey 强制/探测确认支持、未探测)→ 走原有 CC→Responses -// 转换路径(保留旧行为,存量未探测账号零兼容破坏) +// 当前路由策略: +// - CN 账号以 credentials.api_protocol 为权威;adaptive/chat_completions 入站 Chat +// 直转原生 CC,anthropic 走原生 Anthropic,responses 走 Responses +// - 其他 APIKey 账号仍按覆盖模式/探测标记分流(详见 +// openai_compat.ShouldUseResponsesAPI) func (s *OpenAIGatewayService) ForwardAsChatCompletions( ctx context.Context, c *gin.Context, @@ -59,6 +60,14 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( defaultMappedModel string, ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) + ClearActualOpenAIUpstreamEndpoint(c) + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") + } + setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } restrictionResult := s.detectCodexClientRestriction(c, account, body) logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) @@ -87,9 +96,50 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } - // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 - // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // Cursor compatibility: some clients send a Responses-shaped body to the + // /v1/chat/completions URL. Detect it before adaptive routing so adaptive + // accounts never forward the body unchanged to a Chat Completions endpoint. + isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() + + // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 + // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM + // 没有 Responses 端点,先转换成 Chat Completions 再直转。 + if account.IsAdaptiveAPIProtocol() { + if !isResponsesShape { + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } + if account.Platform != PlatformDeepseek { + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(body, &responsesReq); err != nil { + return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) + } + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( + &responsesReq, + &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, + ) + if err != nil { + return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) + } + chatBody, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal converted chat completions request: %w", err) + } + return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) + } + // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 + } + + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, + // CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于 + // ShouldUseResponsesAPI 分流:该类账号经 probe 落标 + // openai_responses_supported=false,会先命中下方的 CC 直转分支。 + if account.IsAnthropicProtocol() { + return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) + } + + // 固定 chat_completions 的 CN 账号,以及强制或已探测确认不支持 Responses + // 的其他 APIKey 账号,均走 CC 直转。 + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -110,7 +160,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey = strings.TrimSpace(promptCacheKey) compatPromptCacheInjected := false - if promptCacheKey == "" && account.Type == AccountTypeOAuth && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { + if promptCacheKey == "" && account.UsesOpenAICodexProtocol() && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { promptCacheKey = deriveCompatPromptCacheKey(&chatReq, upstreamModel) compatPromptCacheInjected = promptCacheKey != "" } @@ -124,11 +174,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( // and produce `input: null`, which Codex upstreams reject with // "Invalid type for 'input': expected a string, but got an object". // - // Detect that shape and forward the raw body as-is, only rewriting `model` + // Forward that shape as-is, only rewriting `model` // to the resolved upstream model. The downstream codex OAuth transform will // still normalize store/stream/instructions/etc. - isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() - var ( responsesReq *apicompat.ResponsesRequest responsesBody []byte @@ -149,7 +197,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( responsesBody = stripped } } - responsesBody, normalizedServiceTier, err := normalizeResponsesBodyServiceTier(responsesBody) + var normalizedServiceTier string + responsesBody, normalizedServiceTier, err = normalizeResponsesBodyServiceTier(responsesBody) if err != nil { return nil, fmt.Errorf("normalize service_tier in responses-shape body: %w", err) } @@ -193,7 +242,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } logger.L().Debug("openai chat_completions: model mapping applied", logFields...) - if account.Type == AccountTypeOAuth { + if account.UsesOpenAICodexProtocol() { var reqBody map[string]any if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) @@ -203,6 +252,11 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( SkipDefaultInstructions: !isResponsesShape, OmitPromotedSystemMessagesFromInput: !isResponsesShape && !isJSONObjectFormat, }) + if codexResult.Error != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}}) + return nil, codexResult.Error + } + setCodexToolNameReverse(c, codexResult.ToolNameReverse) if !isResponsesShape { ensureCodexOAuthInstructionsField(reqBody) } @@ -214,11 +268,15 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } else if promptCacheKey != "" { reqBody["prompt_cache_key"] = promptCacheKey } + applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) responsesBody, err = json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("remarshal after codex transform: %w", err) } } + // Codex transforms may normalize the model after the initial mapping pass; + // record the final slug immediately before policy/auth/upstream dispatch. + SetOpsUpstreamModel(c, upstreamModel) if account.Type == AccountTypeAPIKey { if trimmedKey := strings.TrimSpace(promptCacheKey); trimmedKey != "" { @@ -264,7 +322,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if promptCacheKey != "" { apiKeyID := getAPIKeyIDFromContext(c) - upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey))) + upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey))) } // 7. Send request @@ -272,7 +330,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -322,11 +380,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, handleErr } - // Propagate ServiceTier and ReasoningEffort to result for billing + // Propagate ServiceTier and ReasoningEffort to result for billing. + // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 + // fast policy filter/force 之后)里的 tier,policy filter 删掉字段后不再 + // 按原请求 Fast 计费。 if handleErr == nil && result != nil { - if responsesReq.ServiceTier != "" { - st := responsesReq.ServiceTier - result.ServiceTier = &st + if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { + result.ServiceTier = tier } if responsesReq.Reasoning != nil && responsesReq.Reasoning.Effort != "" { re := responsesReq.Reasoning.Effort @@ -340,6 +400,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if handleErr == nil && account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } return result, handleErr @@ -412,9 +474,9 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai chat_completions buffered", requestID) + finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai chat_completions buffered", requestID) if err != nil { - return nil, err + return nil, s.newOpenAICompatBufferedReadFailoverError(c, account, resp, requestID, err) } if finalResponse == nil { @@ -426,6 +488,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( observer = beginUpstreamResponseModelObservation(c) } observer.Observe(finalResponse.Model, true) + observer.ObserveServiceTier(finalResponse.ServiceTier, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) // cyber_policy 致命不可重试:不 failover,以 Chat Completions 错误格式回写(F4), @@ -466,6 +529,9 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", message) return nil, fmt.Errorf("upstream response failed: %s", message) } + if strings.TrimSpace(finalResponse.Status) == "completed" { + logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, "response.completed", false) + } if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, finalResponse.Model) && !hasBillableGrokChatUsage(usage) { upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) @@ -496,6 +562,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: false, Duration: time.Since(startTime), } @@ -510,6 +577,47 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( return result, nil } +func (s *OpenAIGatewayService) newOpenAICompatBufferedReadFailoverError( + c *gin.Context, + account *Account, + resp *http.Response, + requestID string, + err error, +) error { + var readErr *openAICompatBufferedReadError + if !errors.As(err, &readErr) || readErr == nil || errors.Is(readErr.cause, bufio.ErrTooLong) { + return err + } + var requestContext context.Context + if c != nil && c.Request != nil { + requestContext = c.Request.Context() + } + if !shouldClassifyOpenAIUpstreamStreamReadError(readErr.cause, requestContext) { + return err + } + + classifiedErr := newOpenAIUpstreamStreamReadError(readErr.cause) + code, message, ok := OpenAIUpstreamStreamReadErrorDetails(classifiedErr) + if !ok { + return err + } + payload, _ := json.Marshal(gin.H{ + "error": gin.H{ + "type": "upstream_error", + "code": code, + "message": message, + }, + }) + var responseHeaders http.Header + if resp != nil { + responseHeaders = resp.Header + } + failoverErr := s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message, responseHeaders) + // 保留稳定错误码,确保重试耗尽后客户端和错误透传规则仍能识别传输故障。 + failoverErr.ResponseBody = payload + return failoverErr +} + // handleChatStreamingResponse reads Responses SSE events from upstream, // converts each to Chat Completions SSE chunks, and writes them to the client. func (s *OpenAIGatewayService) handleChatStreamingResponse( @@ -540,6 +648,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen) var streamFailoverErr *UpstreamFailoverError var streamNonFailoverErr error + terminalEventType := "" // Grok chat bridge reuses Responses SSE; count native search tools for surcharge. searchCount := 0 streamSearchSeen := make(map[string]struct{}) @@ -574,6 +683,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -585,6 +695,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } processDataLine := func(payload string) bool { + payload = string(restoreCodexToolNamesFromContext(c, []byte(payload))) if firstChunk { firstChunk = false ms := int(time.Since(startTime).Milliseconds()) @@ -604,9 +715,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } observer.ObserveOpenAI([]byte(payload), event.Type) refusalDetector.ObservePayload([]byte(payload)) + s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type) if isTerminalEvent { + terminalEventType = strings.TrimSpace(event.Type) if event.Usage != nil { usage = copyOpenAIUsageFromResponsesUsage(event.Usage) } @@ -614,7 +727,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage) } } - if strings.TrimSpace(event.Type) == "response.failed" { + if strings.TrimSpace(event.Type) == "response.failed" || strings.TrimSpace(event.Type) == "error" { payloadBytes := []byte(payload) message := extractOpenAISSEErrorMessage(payloadBytes) if hit, code, msg := detectOpenAICyberPolicy(payloadBytes); hit { @@ -648,7 +761,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } return true } - if openAIStreamFailedEventShouldFailover(payloadBytes, message) { + shouldFailover := openAIStreamFailedEventShouldFailover(payloadBytes, message) + if strings.TrimSpace(event.Type) == "error" { + shouldFailover = openAIStreamErrorEventShouldFailover(payloadBytes, message) + } + if !clientOutputStarted && shouldFailover { streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header) return true } @@ -817,6 +934,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( if !clientDisconnected { c.Writer.Flush() } + logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected) return resultWithUsage(), nil } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go new file mode 100644 index 0000000000..6e24fec392 --- /dev/null +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -0,0 +1,486 @@ +package service + +// 国产供应商 Anthropic 协议账号的 CC 入站反向路径。 +// +// 客户端说 OpenAI Chat Completions、上游是供应商原生 Anthropic 端点 +// (api_protocol=anthropic)时的交叉组合:请求 CC→Responses→Anthropic 转换, +// 响应 Anthropic→Responses→CC 转换。转换链与 Anthropic 平台的 +// gateway_forward_as_chat_completions.go 完全一致(复用同一组 apicompat +// 状态机),仅上游发送/错误处理对齐 OpenAI 网关语义。 + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// forwardChatCompletionsViaNativeAnthropic serves OpenAI /v1/chat/completions +// clients through a CN provider's native Anthropic endpoint. +// +// Conversion chain: +// +// Request: Chat Completions → Responses → Anthropic (chained) +// Response: Anthropic events → Responses events → CC chunks (chained state machines) +func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + // 1. Parse Chat Completions request + var ccReq apicompat.ChatCompletionsRequest + if err := json.Unmarshal(body, &ccReq); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return nil, fmt.Errorf("parse chat completions request: %w", err) + } + originalModel := ccReq.Model + if strings.TrimSpace(originalModel) == "" { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := ccReq.Stream + includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage + + // 2. Convert CC → Responses → Anthropic (chained conversion) + responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") + return nil, fmt.Errorf("convert chat completions to responses: %w", err) + } + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Model mapping(OpenAI 网关统一入口的映射语义) + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + anthropicReq.Model = upstreamModel + + // 4. Force upstream streaming(客户端原始终决定响应格式; + // 上游恒为流式,非流式由缓冲路径组装)。 + anthropicReq.Stream = true + reqStream := true + + logger.L().Debug("openai chat_completions: forwarding via native anthropic endpoint", + zap.Int64("account_id", account.ID), + zap.String("original_model", originalModel), + zap.String("billing_model", billingModel), + zap.String("upstream_model", upstreamModel), + zap.Bool("client_stream", clientStream), + ) + + anthropicBody, err := json.Marshal(anthropicReq) + if err != nil { + return nil, fmt.Errorf("marshal anthropic request: %w", err) + } + + // 与 /v1/messages 直通路径相同的 pre-filter。 + anthropicBody = StripEmptyTextBlocks(anthropicBody) + anthropicBody = FilterWebSearchHistoryBlocks(anthropicBody, upstreamModel) + anthropicBody = enforceCacheControlLimit(anthropicBody) + + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if apiKey == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + targetURL, err := s.nativeAnthropicTargetURL(account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL) + releaseUpstreamCtx() + if err != nil { + return nil, fmt.Errorf("build upstream request: %w", err) + } + + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { + return nil, foErr + } + writeChatCompletionsError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) + } + + reasoningEffort := extractCCReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) + + if clientStream { + return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) + } + return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) +} + +// handleCCBufferedFromNativeAnthropic reads Anthropic SSE events, assembles the +// full response, then converts Anthropic → Responses → Chat Completions. +func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + var finalResp *apicompat.AnthropicResponse + var usage ClaudeUsage + + // 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai cc via native anthropic buffered: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + logger.L().Warn("openai cc via native anthropic buffered: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream data interval timeout") + return nil, fmt.Errorf("stream data interval timeout") + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + // SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等 Anthropic 兼容上游 + // 返回紧凑格式,严格匹配 "event: " 会丢弃全部事件(#4653 同根因)。 + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + if event.Type == "message_start" && event.Message != nil { + finalResp = event.Message + mergeAnthropicUsage(&usage, event.Message.Usage) + } + if event.Type == "message_delta" { + if event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil { + finalResp.StopReason = apicompat.AnthropicStopReasonPtr(event.Delta.StopReason) + } + } + if event.Type == "content_block_start" && event.ContentBlock != nil && finalResp != nil { + finalResp.Content = append(finalResp.Content, *event.ContentBlock) + } + if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { + idx := *event.Index + if idx < len(finalResp.Content) { + switch event.Delta.Type { + case "text_delta": + finalResp.Content[idx].Text += event.Delta.Text + case "thinking_delta": + finalResp.Content[idx].Thinking += event.Delta.Thinking + case "input_json_delta": + finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) + } + } + } + } + + if finalResp == nil { + writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response") + return nil, fmt.Errorf("upstream stream ended without response") + } + + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + finalResp.Usage = apicompat.AnthropicUsage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + } + } + + responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) + ccResp := apicompat.ResponsesToChatCompletions(responsesResp, originalModel) + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + // 非流式响应必须是 application/json(上游被强制流式,透传头会污染)。 + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + if respBytes, err := json.Marshal(ccResp); err == nil { + respBytes = reverseToolNamesIfPresent(c, respBytes) + c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) + } else { + c.JSON(http.StatusOK, ccResp) + } + + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +// handleCCStreamingFromNativeAnthropic reads Anthropic SSE events, converts each +// to Responses events, then to Chat Completions chunks, and writes them. +func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, + includeUsage bool, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + anthState := apicompat.NewAnthropicEventToResponsesState() + anthState.Model = originalModel + ccState := apicompat.NewResponsesEventToChatState() + ccState.Model = originalModel + ccState.IncludeUsage = includeUsage + + var usage ClaudeUsage + var firstTokenMs *int + firstChunk := true + clientDisconnected := false + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + resultWithUsage := func() *OpenAIForwardResult { + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, + } + } + + // 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束排水。上游 ctx 为 + // WithoutCancel 且 http.Client 无整体 Timeout,无此界限则客户端断开后 + // scanner.Scan() 永久阻塞(见 anthropic native pump 文件注释)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai cc via native anthropic stream: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + // onIdle 关闭上游连接(解除阻塞的读、归还连接池位),并按已累计 usage + // 返回——与 messages 主路径 "stream usage incomplete after timeout" 同语义。 + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + if !clientDisconnected { + logger.L().Warn("openai cc via native anthropic stream: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + } + return resultWithUsage(), fmt.Errorf("stream data interval timeout") + } + + writeChunk := func(chunk apicompat.ChatCompletionsChunk) bool { + if clientDisconnected { + return false // 已断开:不再写客户端,只排水上游累计 usage + } + sse, err := apicompat.ChatChunkToSSE(chunk) + if err != nil { + return false + } + out := string(reverseToolNamesIfPresent(c, []byte(sse))) + if _, err := fmt.Fprint(c.Writer, out); err != nil { + clientDisconnected = true + return false + } + return false + } + + processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) bool { + if firstChunk { + firstChunk = false + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + // usage 恒累计(含客户端断开后的排水阶段,payg 上游照常计费)。 + if event.Type == "message_delta" && event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Type == "message_start" && event.Message != nil { + mergeAnthropicUsage(&usage, event.Message.Usage) + } + + // 客户端已断开:跳过转换与写出,继续读上游直到流结束(usage 完整、 + // 连接及时归还),不再提前 return。 + if clientDisconnected { + return false + } + + responsesEvents := apicompat.AnthropicEventToResponsesEvents(event, anthState) + for _, resEvt := range responsesEvents { + ccChunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState) + for _, chunk := range ccChunks { + writeChunk(chunk) + } + } + if len(responsesEvents) > 0 { + c.Writer.Flush() + } + return false + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + // EOF / 读错误:事件行后流终止,进入 finalize。 + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + if processAnthropicEvent(&event) { + return resultWithUsage(), nil + } + } + + // Finalize both state machines(客户端已断开时仍执行,保证 usage 汇总完整)。 + finalResEvents := apicompat.FinalizeAnthropicResponsesStream(anthState) + for _, resEvt := range finalResEvents { + ccChunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState) + for _, chunk := range ccChunks { + writeChunk(chunk) //nolint:errcheck + } + } + finalCCChunks := apicompat.FinalizeResponsesChatStream(ccState) + for _, chunk := range finalCCChunks { + writeChunk(chunk) //nolint:errcheck + } + + if !clientDisconnected { + fmt.Fprint(c.Writer, "data: [DONE]\n\n") //nolint:errcheck + c.Writer.Flush() + } + + return resultWithUsage(), nil +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 34c281b28e..7427c6fc9c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -69,12 +69,10 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } clientStream := gjson.GetBytes(body, "stream").Bool() - // 1b. Extract service tier from the raw body before any transformation. - serviceTier := extractOpenAIServiceTierFromBody(body) - // 2. Resolve model mapping (same as ForwardAsChatCompletions) billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + SetOpsUpstreamModel(c, upstreamModel) grokCacheIdentity := "" if account.Platform == PlatformGrok { // Resolve before image bridging or other body rewrites so the fallback is @@ -105,6 +103,16 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, policyErr } upstreamBody = updatedBody + // 计费兜底 tier = 最终出站 body(policy filter/force 后)里的 tier; + // 最终值由 resolvedOpenAIUpstreamServiceTier 决定(上游回显优先)。 + serviceTier := extractOpenAIServiceTierFromBody(upstreamBody) + if account.Platform == PlatformGrok { + strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody) + if stripErr != nil { + return nil, fmt.Errorf("strip redundant Grok Chat view_image tool: %w", stripErr) + } + upstreamBody = strippedBody + } // Grok Composer does not accept image_url parts directly, but Grok Build // can describe the images first. Bridge only this exact failure mode. @@ -149,6 +157,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, fmt.Errorf("normalize Grok chat reasoning effort: %w", err) } } + upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody) logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", zap.Int64("account_id", account.ID), @@ -166,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { - customUA = "sub2api-grok/1.0" + customUA = defaultGrokUpstreamUserAgent() } resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity) if err != nil { @@ -193,11 +202,16 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel) @@ -321,6 +335,8 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } } } + line = applyOllamaCloudRawChatCompletionsSSELine(account, line) + line = stripEmptyChatToolCallIdentityFromSSELine(line) writeLine(line) if line == "" { @@ -372,8 +388,9 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -452,6 +469,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) } + respBody = applyOllamaCloudRawChatCompletionsResponse(account, respBody) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -472,8 +490,9 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index a80234913a..8a83bbc7ea 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -607,6 +607,86 @@ func TestForwardAsRawChatCompletions_SilentRefusalNormalContentExempt(t *testing require.Contains(t, rec.Body.String(), "data: [DONE]") } +// TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity 端到端验证 raw +// CC 流式直转路径剔除 DashScope/DeepSeek 后续参数 delta 的空 id/name: +// 下游仍保留首包合法 id/name 与 arguments 碎片,但后续 delta 不再带 +// `"id":""` / `"name":""`,避免 dsh 等客户端用 `!== undefined` 合并时把 +// 首包合法值覆盖掉(ToolNotFoundError: unknown tool "")。 +func TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"weather"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`, + "", + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`, + "", + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`, + "", + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool_identity"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + + downstream := rec.Body.String() + require.Contains(t, downstream, `"id":"call_example"`) + require.Contains(t, downstream, `"name":"web_search"`) + require.Contains(t, downstream, `{\"query\":`) + require.Contains(t, downstream, `\"example\"}`) + require.Contains(t, downstream, "data: [DONE]") + require.NotContains(t, downstream, `"id":""`) + require.NotContains(t, downstream, `"name":""`) + + // 逐条扫下游 data payload:后续参数 delta 的 tool_calls.0.id / + // function.name 必须已剔除(Exists() == false),首包合法值保留。 + followUpSeen := false + for _, line := range strings.Split(downstream, "\n") { + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + continue + } + trimmed := strings.TrimSpace(payload) + if trimmed == "" || trimmed == "[DONE]" { + continue + } + delta := gjson.Get(payload, "choices.0.delta") + if !delta.Exists() || !delta.Get("tool_calls").Exists() { + continue + } + id := delta.Get("tool_calls.0.id") + if id.String() == "call_example" { + require.Equal(t, "web_search", delta.Get("tool_calls.0.function.name").String()) + continue + } + require.False(t, id.Exists(), "empty id must be stripped: %s", payload) + require.False(t, delta.Get("tool_calls.0.function.name").Exists(), "empty name must be stripped: %s", payload) + require.NotEmpty(t, delta.Get("tool_calls.0.function.arguments").String()) + followUpSeen = true + } + require.True(t, followUpSeen) +} + func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index ededa9f322..e54e2de635 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -489,6 +489,46 @@ func TestForwardAsChatCompletions_StreamContextWindowResponseFailedReturnsErrorW require.NotContains(t, rec.Body.String(), "[DONE]") } +func TestForwardAsChatCompletions_StreamBareErrorAfterOutputDoesNotFailOver(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"partial"}`, + "", + `event: error`, + `data: {"type":"error","error":{"type":"server_error","code":"server_error","message":"temporary upstream failure"}}`, + "", + `data: [DONE]`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ + ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + + require.Error(t, err) + require.NotNil(t, result) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Contains(t, rec.Body.String(), "partial") + require.Contains(t, rec.Body.String(), "temporary upstream failure") + require.NotContains(t, rec.Body.String(), "[DONE]") +} + func TestForwardAsChatCompletions_StreamCyberPolicyNoFailover(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go new file mode 100644 index 0000000000..576f8bfd42 --- /dev/null +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -0,0 +1,254 @@ +//go:build unit + +package service + +// 国产供应商功能修复回归测试: +// 1. CN 分组不适用 /v1/messages 调度级模型映射(openai 的 gpt-5.x 默认值发给 +// CN 上游必错); +// 2. 计费候选链对 CN 账号过滤 claude-* 兜底候选(防按 Claude 原价误计 CN 流量); +// 3. 空候选按 ErrModelPricingUnavailable 处理(零成本落账而非丢弃 usage 记录); +// 4. Responses×anthropic 流式转换器客户端断开后继续排水、usage 汇总完整。 + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + g := &Group{Platform: platform} + require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"), + "CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform) + require.Empty(t, g.ResolveMessagesDispatchModel("claude-opus-4-1"), platform) + } + // 非回归:openai 分组保持原有默认映射行为。 + openaiGroup := &Group{Platform: PlatformOpenAI} + require.NotEmpty(t, openaiGroup.ResolveMessagesDispatchModel("claude-sonnet-4-5"), + "openai 分组的调度默认映射不应受 CN 修复影响") +} + +func TestFilterCNProviderBillingModelCandidates(t *testing.T) { + svc := &OpenAIGatewayService{} // resolver 为 nil → 无显式分组/渠道定价 + apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}} + + cnAccount := &Account{ID: 1, Platform: PlatformKimi} + filtered := svc.filterCNProviderBillingModelCandidates(context.Background(), cnAccount, apiKey, + []string{"kimi-k2-0905-preview", "claude-sonnet-4-5", "moonshot-v1-8k"}) + require.Equal(t, []string{"kimi-k2-0905-preview", "moonshot-v1-8k"}, filtered, + "无显式定价时 claude-* 候选必须被过滤") + + allClaude := svc.filterCNProviderBillingModelCandidates(context.Background(), cnAccount, apiKey, + []string{"claude-sonnet-4-5", "claude-sonnet-4-5"}) + require.Empty(t, allClaude, "全 claude 候选应被清空(上层走零成本+告警落账)") + + // 非 CN 账号完全不受影响。 + openaiAccount := &Account{ID: 2, Platform: PlatformOpenAI} + passthrough := svc.filterCNProviderBillingModelCandidates(context.Background(), openaiAccount, apiKey, + []string{"claude-sonnet-4-5", "gpt-5.4"}) + require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough) + + require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil)) +} + +func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) { + svc := &OpenAIGatewayService{} + apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}} + + _, err := svc.calculateOpenAIRecordUsageCost( + context.Background(), nil, apiKey, nil, + 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, time.Time{}, + ) + require.Error(t, err) + require.True(t, isUsagePricingUnavailableError(err), + "空候选必须按无价可循处理(上层零成本落账),而不是丢弃整条 usage 记录: %v", err) +} + +func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + // failAfter=0:首次写出即失败,模拟客户端断开(复用测试包既有 failingGinWriter)。 + failWriter := &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0} + c.Writer = failWriter + + resp, pr, pw := newHangingUpstreamResponse() + go func() { + // 首事件触发客户端写失败后,末尾 message_delta 才携带最终 output_tokens: + // 断开即弃会把整段生成记成 1 token。 + _, _ = pw.Write([]byte(miniAnthropicSSEStream())) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + res, err := svc.handleResponsesStreamingFromNativeAnthropic( + resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + + require.NoError(t, err, "断开排水至上游自然结束应返回 nil error(usage 走成功路径落账)") + require.NotNil(t, res) + require.True(t, res.ClientDisconnect) + require.Equal(t, 10, res.Usage.InputTokens, "input_tokens 应来自 message_start") + require.Equal(t, 5, res.Usage.OutputTokens, + "output_tokens 必须来自排水读到的末尾 message_delta(断开即弃时会是 1)") +} + +func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ID: 401, Platform: platform, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), + account, + http.StatusForbidden, + http.Header{}, + []byte("Access denied by CDN"), + ) + + require.False(t, shouldDisable, "%s: HTML 403(CDN/代理拦截页)不得作为账号失效证据", platform) + require.Equal(t, 0, repo.setErrorCalls, "%s: 不得永久禁用账号", platform) + require.Equal(t, 0, repo.tempCalls, "%s: 不得临时停调账号", platform) + } +} + +func TestHandle403_CNProviderStructured403TempUnschedulableFirstHit(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &openAI403CounterCacheStub{counts: []int64{1}} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetOpenAI403CounterCache(counter) + account := &Account{ID: 402, Platform: PlatformKimi, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), + account, + http.StatusForbidden, + http.Header{}, + []byte(`{"error":{"message":"forbidden"}}`), + ) + + require.True(t, shouldDisable) + require.Equal(t, 0, repo.setErrorCalls, "首次结构化 403 应临时停调而非永久禁用") + require.Equal(t, 1, repo.tempCalls) + require.Contains(t, repo.lastTempReason, "(1/3)") +} + +func TestIsCNProviderConcurrencyLimit403_ExactClassification(t *testing.T) { + kimi := &Account{Platform: PlatformKimi} + + require.True(t, isCNProviderConcurrencyLimit403(kimi, kimiConcurrentRequestLimitMessage)) + require.True(t, isCNProviderConcurrencyLimit403(kimi, " "+kimiConcurrentRequestLimitMessage+"\n")) + + for name, tc := range map[string]struct { + account *Account + message string + }{ + "permission denied": {kimi, "You do not have permission to access this resource."}, + "generic concurrency wording": {kimi, "concurrent request limit reached"}, + "near match missing punctuation": {kimi, "You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again"}, + "other CN provider": {&Account{Platform: PlatformZhipu}, kimiConcurrentRequestLimitMessage}, + "non CN provider": {&Account{Platform: PlatformOpenAI}, kimiConcurrentRequestLimitMessage}, + "nil account": {nil, kimiConcurrentRequestLimitMessage}, + } { + t.Run(name, func(t *testing.T) { + require.False(t, isCNProviderConcurrencyLimit403(tc.account, tc.message)) + }) + } +} + +func TestHandle403_OtherCNProviderWithKimiConcurrencyMessageUsesNormalPolicy(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}} + blocker := &runtimeBlockRecorder{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetOpenAI403CounterCache(counter) + service.SetAccountRuntimeBlocker(blocker) + account := &Account{ID: 405, Platform: PlatformZhipu, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, + []byte(`{"error":{"message":"You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."}}`), + ) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.setErrorCalls, "non-Kimi CN provider must retain the normal permanent-error policy") + require.Equal(t, 0, repo.tempCalls) + require.Empty(t, counter.counts, "normal CN 403 policy must consume the counter result") + require.Equal(t, []string{"auth_error"}, blocker.reasons, "the Kimi-specific runtime block must not apply") +} + +func TestHandle403_CNProviderConcurrencyLimitAlwaysUsesTemporaryCooldown(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}} + blocker := &runtimeBlockRecorder{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetOpenAI403CounterCache(counter) + service.SetAccountRuntimeBlocker(blocker) + account := &Account{ID: 403, Platform: PlatformKimi, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, + []byte(`{"error":{"message":"You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."}}`), + ) + + require.True(t, shouldDisable, "the request must still fail over to another account") + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 1, repo.tempCalls) + require.Contains(t, repo.lastTempReason, cnConcurrencyLimitReasonPrefix) + require.Equal(t, []int64{openAI403DisableThreshold}, counter.counts, "transient concurrency 403 must bypass the permanent-error counter") + require.Len(t, blocker.accounts, 1) + require.Equal(t, cnConcurrencyLimitReasonPrefix, blocker.reasons[0]) + require.True(t, blocker.until[0].After(time.Now())) +} + +func TestHandle403_KimiConcurrencyLimitRepositoryFailureKeepsRuntimeBlock(t *testing.T) { + repo := &rateLimitAccountRepoStub{tempErr: errors.New("repository unavailable")} + counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}} + blocker := &runtimeBlockRecorder{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetOpenAI403CounterCache(counter) + service.SetAccountRuntimeBlocker(blocker) + account := &Account{ID: 406, Platform: PlatformKimi, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, + []byte(`{"error":{"message":"You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."}}`), + ) + + require.True(t, shouldDisable, "the current request must fail over even when persistence fails") + require.Equal(t, 1, repo.tempCalls, "the temporary cooldown should still be persisted when possible") + require.Equal(t, 0, repo.setErrorCalls, "persistence failure must not fall back to permanent account error") + require.Equal(t, []int64{openAI403DisableThreshold}, counter.counts, "persistence failure must not enter the permanent-error counter path") + require.Len(t, blocker.accounts, 1, "the in-memory runtime block must survive repository failure") + require.Same(t, account, blocker.accounts[0]) + require.Equal(t, cnConcurrencyLimitReasonPrefix, blocker.reasons[0]) + require.True(t, blocker.until[0].After(time.Now())) +} + +func TestHandle403_CNProviderNearMatchRetainsNormalPermanentErrorPolicy(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetOpenAI403CounterCache(counter) + account := &Account{ID: 404, Platform: PlatformKimi, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), account, http.StatusForbidden, http.Header{}, + []byte(`{"error":{"message":"You've reached your concurrent request limit. Please contact support."}}`), + ) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.setErrorCalls, "non-exact 403 must retain existing permission/auth protection") + require.Equal(t, 0, repo.tempCalls) +} diff --git a/backend/internal/service/openai_gateway_compat_buffered_read_failover_test.go b/backend/internal/service/openai_gateway_compat_buffered_read_failover_test.go new file mode 100644 index 0000000000..c28bef796a --- /dev/null +++ b/backend/internal/service/openai_gateway_compat_buffered_read_failover_test.go @@ -0,0 +1,152 @@ +package service + +import ( + "bufio" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type openAICompatBufferedReadErrorCloser struct { + err error +} + +func (r *openAICompatBufferedReadErrorCloser) Read([]byte) (int, error) { return 0, r.err } +func (r *openAICompatBufferedReadErrorCloser) Close() error { return nil } + +func TestChatCompletionsBufferedResponsesReadErrorReturnsFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + readErrors := []struct { + name string + err error + expectedCode string + }{ + {name: "unexpected_eof", err: io.ErrUnexpectedEOF, expectedCode: OpenAIUpstreamStreamReadErrorCode}, + {name: "http2_reset", err: errors.New("stream error: stream ID 7; INTERNAL_ERROR; received from peer"), expectedCode: OpenAIUpstreamHTTP2StreamErrorCode}, + } + + for _, readError := range readErrors { + t.Run(readError.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"upstream-rid"}}, + Body: &openAICompatBufferedReadErrorCloser{err: readError.err}, + } + account := &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI} + + result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse( + resp, c, account, "gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", time.Now(), + ) + + require.Error(t, err) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, "upstream-rid", failoverErr.ResponseHeaders.Get("x-request-id")) + require.Equal(t, readError.expectedCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) + require.Empty(t, rec.Body.String()) + require.False(t, c.Writer.Written()) + }) + } +} + +func TestChatCompletionsBufferedResponsesReadErrorDoesNotFailoverAfterClientCancel(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + requestContext, cancel := context.WithCancel(context.Background()) + cancel() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(requestContext) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &openAICompatBufferedReadErrorCloser{err: io.ErrUnexpectedEOF}, + } + + result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse( + resp, + c, + &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}, + "gpt-5.6-sol", + "gpt-5.6-sol", + "gpt-5.6-sol", + time.Now(), + ) + + require.Error(t, err) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) + require.Empty(t, rec.Body.String()) +} + +func TestChatCompletionsBufferedResponsesOversizedLineDoesNotFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &openAICompatBufferedReadErrorCloser{err: bufio.ErrTooLong}, + } + + result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse( + resp, + c, + &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}, + "gpt-5.6-sol", + "gpt-5.6-sol", + "gpt-5.6-sol", + time.Now(), + ) + + require.ErrorIs(t, err, bufio.ErrTooLong) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) +} + +func TestAnthropicBufferedResponsesReadErrorKeepsExistingBehavior(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &openAICompatBufferedReadErrorCloser{err: io.ErrUnexpectedEOF}, + } + + result, err := (&OpenAIGatewayService{}).handleAnthropicBufferedStreamingResponse( + resp, + c, + &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}, + "gpt-5.6-sol", + "gpt-5.6-sol", + "gpt-5.6-sol", + time.Now(), + ) + + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + require.Equal(t, io.ErrUnexpectedEOF, err, "Messages 路径必须保持原始读取错误,不引入 Chat failover 包装") + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) +} diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index e56b33df45..1480a2edd3 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" @@ -39,10 +40,187 @@ type openAIInputTokensCountPrepared struct { UpstreamModel string } +// ForwardResponsesInputTokens handles the native OpenAI +// POST /v1/responses/input_tokens shape. Custom OpenAI-compatible relays often +// implement /responses but not this preflight endpoint, so those accounts use +// the local estimator instead of receiving a request that is known to fail. +func (s *OpenAIGatewayService) ForwardResponsesInputTokens( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, +) error { + if account == nil { + writeOpenAIResponsesInputTokensError(c, http.StatusServiceUnavailable, "api_error", "No available OpenAI accounts") + return fmt.Errorf("responses input_tokens: missing account") + } + + prepared, err := prepareNativeOpenAIInputTokensCountRequest(body, account) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return err + } + + if shouldEstimateOpenAIInputTokensLocally(account) { + writeOpenAIResponsesInputTokensFallback(c, account, prepared, 0, "custom_relay") + return nil + } + + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") + return fmt.Errorf("responses input_tokens: get access token: %w", err) + } + + upstreamBody := ReplaceModelInBody(body, prepared.UpstreamModel) + upstreamReq, err := s.buildInputTokensUpstreamRequest(ctx, c, account, upstreamBody, token) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") + return fmt.Errorf("responses input_tokens: build upstream request: %w", err) + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) + if err != nil { + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + setOpsUpstreamError(c, 0, safeErr, "") + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") + return fmt.Errorf("responses input_tokens: upstream request failed: %s", safeErr) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") + return fmt.Errorf("responses input_tokens: read upstream response: %w", err) + } + + if resp.StatusCode >= 400 { + if isOpenAIResponsesInputTokensUnsupported(account, resp.StatusCode, respBody) { + writeOpenAIResponsesInputTokensFallback(c, account, prepared, resp.StatusCode, "upstream_unsupported") + return nil + } + if s.rateLimitService != nil { + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "") + writeOpenAIResponsesInputTokensError(c, resp.StatusCode, "upstream_error", "Upstream request failed") + if upstreamMsg == "" { + return fmt.Errorf("responses input_tokens: upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("responses input_tokens: upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + } + + inputTokens := gjson.GetBytes(respBody, "input_tokens") + if !inputTokens.Exists() { + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response missing input_tokens") + return fmt.Errorf("responses input_tokens: upstream response missing input_tokens") + } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + c.Data(http.StatusOK, contentType, respBody) + return nil +} + +func prepareNativeOpenAIInputTokensCountRequest(body []byte, account *Account) (*openAIInputTokensCountPrepared, error) { + var req openAIInputTokensCountRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("parse responses input_tokens request: %w", err) + } + originalModel := strings.TrimSpace(req.Model) + if originalModel == "" { + return nil, fmt.Errorf("parse responses input_tokens request: model is required") + } + billingModel := resolveOpenAIForwardModel(account, originalModel, "") + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + req.Model = upstreamModel + return &openAIInputTokensCountPrepared{ + Request: req, + OriginalModel: originalModel, + NormalizedModel: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + }, nil +} + +func shouldEstimateOpenAIInputTokensLocally(account *Account) bool { + if account == nil || account.IsGrok() || account.IsCNProvider() || account.Type == AccountTypeUpstream { + return true + } + if account.Type != AccountTypeAPIKey { + return false + } + rawBaseURL := strings.TrimSpace(account.GetCredential("base_url")) + if rawBaseURL == "" { + return false + } + parsed, err := url.Parse(rawBaseURL) + if err != nil { + return true + } + return !strings.EqualFold(parsed.Hostname(), "api.openai.com") +} + +func isOpenAIResponsesInputTokensUnsupported(account *Account, statusCode int, body []byte) bool { + if statusCode == http.StatusNotFound { + return true + } + return account != nil && account.Type == AccountTypeOAuth && isOpenAIOAuthInputTokensUnsupported(statusCode, body) +} + +func writeOpenAIResponsesInputTokensFallback(c *gin.Context, account *Account, prepared *openAIInputTokensCountPrepared, statusCode int, reason string) { + estimated := openAIInputTokensFallbackMinimum + if prepared != nil { + if got, err := estimateOpenAIInputTokens(prepared.Request); err == nil && got > 0 { + estimated = got + } + } + accountID := int64(0) + upstreamModel := "" + if account != nil { + accountID = account.ID + } + if prepared != nil { + upstreamModel = prepared.UpstreamModel + } + logger.L().Info("openai responses input_tokens: local estimate fallback", + zap.Int64("account_id", accountID), + zap.Int("upstream_status", statusCode), + zap.Int("estimated_input_tokens", estimated), + zap.String("upstream_model", upstreamModel), + zap.String("reason", reason), + ) + c.JSON(http.StatusOK, gin.H{ + "object": "response.input_tokens", + "input_tokens": estimated, + }) +} + +func writeOpenAIResponsesInputTokensError(c *gin.Context, status int, errType, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": message, + }, + }) +} + // EstimateGrokCountTokens estimates an Anthropic-compatible count_tokens request // locally. Grok does not expose a compatible token-counting endpoint, so this // path deliberately avoids account selection, credentials, and upstream calls. func EstimateGrokCountTokens(body []byte) (int, error) { + return estimateAnthropicCountTokensLocally(body) +} + +// estimateAnthropicCountTokensLocally 走 Anthropic→Responses→tiktoken 链本地估算 +// count_tokens,不发任何上游请求(上游无兼容端点的平台使用)。 +func estimateAnthropicCountTokensLocally(body []byte) (int, error) { var anthropicReq apicompat.AnthropicRequest if err := json.Unmarshal(body, &anthropicReq); err != nil { return 0, fmt.Errorf("parse anthropic count_tokens request: %w", err) @@ -64,7 +242,7 @@ func EstimateGrokCountTokens(body []byte) (int, error) { ToolChoice: responsesReq.ToolChoice, }) if err != nil { - return 0, fmt.Errorf("estimate grok input tokens: %w", err) + return 0, fmt.Errorf("estimate input tokens: %w", err) } if estimated < openAIInputTokensFallbackMinimum { estimated = openAIInputTokensFallbackMinimum @@ -86,6 +264,29 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( return fmt.Errorf("count_tokens: missing account") } + // 国产供应商(全部协议,含 anthropic):一律本地估算,不发上游请求。 + // 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供 + // /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点 + // (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注 + // count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游 + // 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code + // 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。 + if account.IsCNProvider() { + estimated, err := estimateAnthropicCountTokensLocally(body) + if err != nil { + writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return fmt.Errorf("count_tokens: estimate cn provider input tokens: %w", err) + } + logger.L().Debug("openai count_tokens: cn provider local estimate", + zap.Int64("account_id", account.ID), + zap.Int("estimated_input_tokens", estimated), + ) + c.JSON(http.StatusOK, gin.H{ + "input_tokens": estimated, + }) + return nil + } + prepared, err := prepareOpenAIInputTokensCountRequest(body, account, defaultMappedModel) if err != nil { writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") @@ -122,7 +323,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index f7eb759346..9a4c2389e3 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -20,8 +20,22 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) + ClearActualOpenAIUpstreamEndpoint(c) + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") + } + filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + if filterErr != nil { + return nil, filterErr + } + body = filteredBody clearGrokResponsesClientToolMapping(c) + clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) + setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 canonicalImageIntentBody := body @@ -47,22 +61,42 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } + legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body) + if legacyIngressErr != nil { + return nil, legacyIngressErr + } + if legacyIngressChanged { + body = legacyIngressBody + } // 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正 // 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502, // 同一份坏定义在账号池里反复重放。 - sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body) - if toolSchemaErr != nil { - return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr) - } - if toolSchemaSanitized { + if sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolSchemasForPlatform(body, account.Platform); toolSchemaErr != nil { + return nil, toolSchemaErr + } else if toolSchemaSanitized { body = sanitizedToolBody } - if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { - liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) + if account.IsOpenAIOAuthLike() { + reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body) + if reasoningErr != nil { + return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr) + } + if reasoningChanged { + body = reasoningBody + } + } + responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) + if responsesLite { + liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) if liteErr != nil { + param := "tools" + var validationErr *openAIResponsesLiteValidationError + if errors.As(liteErr, &validationErr) { + param = validationErr.param + } setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "") c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ - "type": "invalid_request_error", "message": liteErr.Error(), "param": "tools", + "type": "invalid_request_error", "message": liteErr.Error(), "param": param, }}) return nil, liteErr } @@ -99,6 +133,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + nativeDeepSeekResponses := account.Platform == PlatformDeepseek && + (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) + if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath && + needsOpenAIResponsesClientToolAdaptation(body) { + adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) + if adaptErr != nil { + return nil, fmt.Errorf("adapt DeepSeek Responses client tools: %w", adaptErr) + } + body = adaptedBody + setOpenAIResponsesClientToolMapping(c, mapping) + } + originalBody := body requestView := newOpenAIRequestView(body) reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey @@ -108,10 +154,34 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) } - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合 + // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。 + // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 + if account.IsAnthropicProtocol() { + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) + } + if account.IsOpenAIApiKey() { + if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil { + return nil, normalizeErr + } else if changed { + body = normalized + originalBody = normalized + } + if normalized, changed, normalizeErr := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, isOpenAIResponsesCompactPath(c)); normalizeErr != nil { + return nil, normalizeErr + } else if changed { + body = normalized + originalBody = normalized + } + requestView = newOpenAIRequestView(body) + reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey + originalModel = reqModel + } + + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } - if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { + if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) { sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body) if sanitizeErr != nil { return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr) @@ -195,6 +265,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } bodyModified := false + clientPromptCacheKey := promptCacheKey var reqBody map[string]any ensureReqBody := func() (map[string]any, error) { if requestView.HasPatches() { @@ -278,45 +349,47 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge { + if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } - billingModel := account.GetMappedModel(reqModel) - if billingModel != reqModel { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI) - reqModel = billingModel - markPatchSet("model", billingModel) - } - upstreamModel := billingModel isCompactRequest := compactPath - compactMapped := false + requestedModel := reqModel + billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest) if isCompactRequest { - compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) - if compactMappedModel != "" && compactMappedModel != billingModel { - compactMapped = true - upstreamModel = compactMappedModel - reqModel = compactMappedModel - markPatchSet("model", compactMappedModel) - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI) + if compactModel := s.resolveOpenAICompactFallbackModel(account, requestedModel); compactModel != "" { + upstreamModel = compactModel } } - if !compactMapped { - modelForNormalize := reqModel - if modelForNormalize == "" { - modelForNormalize = requestView.Model - } - upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize) - if upstreamModel != "" && upstreamModel != modelForNormalize { - logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI) - reqModel = upstreamModel - markPatchSet("model", upstreamModel) + if billingModel != requestedModel { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI) + } + reqModel = billingModel + if upstreamModel != requestedModel { + markPatchSet("model", upstreamModel) + } + if upstreamModel != billingModel { + if isCompactRequest { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, upstreamModel, account.Name, isCodexCLI) + } else { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", billingModel, upstreamModel, account.Name, account.Type, isCodexCLI) } } if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" { markPatchSet("reasoning.effort", "none") logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name) } + if strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()) == "json_schema" || + strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String()) == "json_schema" { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if normalizeOpenAIResponseFormatSchemas(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized Responses JSON schema compatibility") + } + } imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel) if imageIntent && !imageGenerationAllowed { @@ -394,7 +467,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } - if account.Type == AccountTypeOAuth { + if account.UsesOpenAICodexProtocol() { decoded, decodeErr := ensureReqBody() if decodeErr != nil { return nil, decodeErr @@ -407,6 +480,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } else { codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest) } + if codexResult.Error != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}}) + return nil, codexResult.Error + } + setCodexToolNameReverse(c, codexResult.ToolNameReverse) if codexResult.Modified { markDecodedModified() } @@ -414,6 +492,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if !isCompactRequest && applyCodexClientMetadata(decoded, account) { markDecodedModified() } + if currentClientPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok { + clientPromptCacheKey = currentClientPromptCacheKey + } + // Account namespace is orthogonal to fingerprint convergence: preserve + // each client's identity cardinality, but never reuse it across OAuth + // credentials after scheduler failover. + if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) { + markDecodedModified() + } + stageCodexFingerprintIDs(c, nil) // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 if !isCompactRequest { @@ -427,15 +515,23 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markDecodedModified() } } - // 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用 - if c != nil && fpIDs != nil { - c.Set("codex_fingerprint_ids", fpIDs) - } + // 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。 + // 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一 + // 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。 + stageCodexFingerprintIDs(c, fpIDs) } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } - if codexResult.PromptCacheKey != "" { + if strings.TrimSpace(clientPromptCacheKey) != "" { + // The body now carries an account-scoped value. Keep the original here + // so the header builder derives the same namespace exactly once. + promptCacheKey = clientPromptCacheKey + } else if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + // Fingerprint convergence may inject a default key when the client did + // not provide one; preserve that existing fallback. + promptCacheKey = currentPromptCacheKey + } else if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } } @@ -448,7 +544,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") if maxOutputTokens.Exists() { switch account.Platform { - case PlatformOpenAI: + case PlatformOpenAI, PlatformDeepseek: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -488,7 +584,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } } - if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() { + if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && + !account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() { markPatchDelete("previous_response_id") } if openAIRequestBodyMayContainEmptyBase64InputImage(body) { @@ -527,6 +624,29 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + if account.UsesOpenAICodexProtocol() { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if input, ok := decoded["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs( + decoded, + input, + strings.TrimSpace(firstNonEmptyString(decoded["previous_response_id"])) != "", + ) { + markDecodedModified() + } + } + if reqBody != nil || openAIResponsesInputMayNeedTruncation(body) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if truncateOpenAIResponsesInputText(decoded) { + markDecodedModified() + } + } + if bodyModified { if requestView.HasPatches() { if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil { @@ -549,6 +669,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView = newOpenAIRequestView(body) } } + // Run after orphan-output filtering and all request-map rebuilds so a + // compaction trigger cannot remain ahead of surviving history items. + if normalizedBody, changed, normalizeErr := NormalizeCompactionTriggerInputOrder(body); normalizeErr != nil { + return nil, fmt.Errorf("normalize compaction trigger order: %w", normalizeErr) + } else if changed { + body = normalizedBody + requestView = newOpenAIRequestView(body) + reqBody = nil + } imageBillingModel := "" imageSizeTier := "" imageInputSize := "" @@ -569,12 +698,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco imageSizeTier = imageCfg.SizeTier imageInputSize = imageCfg.InputSize } - // Get access token token, _, err := s.GetAccessToken(ctx, account) if err != nil { return nil, err } + SetOpsUpstreamModel(c, upstreamModel) // 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。 if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 { @@ -673,6 +802,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco c, account, wsReqBody, + clientPromptCacheKey, token, wsDecision, isCodexCLI, @@ -812,8 +942,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } httpInvalidEncryptedContentRetryTried := false + compactModelFallbackRetried := false agentTaskRecoveryTried := false - rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body) + rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body) for { // Build upstream request upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) @@ -842,7 +973,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Send request upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if headerGuard != nil && headerGuard.stopHeaderWait() { if resp != nil && resp.Body != nil { @@ -915,6 +1046,24 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name) continue } + if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry( + c, account, requestedModel, body, resp.StatusCode, upstreamMsg, respBody, compactModelFallbackRetried, + ); retry { + s.appendOpenAICompactFallbackRetryOps(c, account, resp, respBody, upstreamMsg, false) + fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + body = retryBody + requestView = newOpenAIRequestView(body) + reqBody = nil + upstreamModel = fallbackModel + compactModelFallbackRetried = true + SetOpsUpstreamModel(c, fallbackModel) + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)", + account.Name, fromModel, fallbackModel, upstreamCode, + ) + continue + } if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { @@ -936,18 +1085,28 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco }) shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel) - return nil, newOpenAIUpstreamFailoverError( + return nil, s.newOpenAIAccountFailoverError( + account, resp.StatusCode, resp.Header, respBody, upstreamMsg, + shouldDisable, !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)), ) } - return s.handleErrorResponse(ctx, resp, c, account, body, billingModel) + return s.handleErrorResponse(ctx, resp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel)) } defer func() { _ = resp.Body.Close() }() + if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) { + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + resp.Body = newResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize) + } + serviceTier := extractOpenAIServiceTierFromBody(body) // 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。 reqBody = nil @@ -962,6 +1121,40 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if reqStream { streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue) if err != nil { + if signal, ok := asOpenAICompactFallbackSignal(err); ok { + if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry( + c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried, + ); retry { + s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, false) + body = retryBody + requestView = newOpenAIRequestView(body) + upstreamModel = fallbackModel + compactModelFallbackRetried = true + SetOpsUpstreamModel(c, fallbackModel) + continue + } + if resp.Body != nil { + _ = resp.Body.Close() + } + compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal) + if s.shouldFailoverOpenAIUpstreamResponse(compactResp.StatusCode, signal.message, compactBody) { + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: compactResp.StatusCode, + UpstreamRequestID: compactResp.Header.Get("x-request-id"), + Kind: "failover", + Message: signal.message, + }) + shouldDisable := s.handleFailoverSideEffects(ctx, compactResp, account, compactBody, upstreamModel) + return nil, s.newOpenAIAccountFailoverError( + account, compactResp.StatusCode, compactResp.Header, compactBody, signal.message, shouldDisable, + !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(compactResp.StatusCode) || isOpenAITransientProcessingError(compactResp.StatusCode, signal.message, compactBody)), + ) + } + return s.handleErrorResponse(ctx, compactResp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel)) + } return nil, err } usage = streamResult.usage @@ -973,6 +1166,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } else { nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) if err != nil { + if signal, ok := asOpenAICompactFallbackSignal(err); ok { + if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry( + c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried, + ); retry { + body = retryBody + requestView = newOpenAIRequestView(body) + upstreamModel = fallbackModel + compactModelFallbackRetried = true + SetOpsUpstreamModel(c, fallbackModel) + continue + } + } return nil, err } usage = nonStreamResult.usage @@ -985,10 +1190,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Extract and save Codex usage snapshot from response headers (for OAuth accounts). // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 - if account.Type == AccountTypeOAuth && !account.IsShadow() { + if account.UsesOpenAICodexProtocol() && !account.IsShadow() { if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } if usage == nil { @@ -1004,7 +1211,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), - ServiceTier: serviceTier, + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), ReasoningEffort: reasoningEffort, Stream: reqStream, OpenAIWSMode: false, @@ -1028,6 +1236,25 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } +func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + if account.IsCNProvider() { + // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 + // Responses,Kimi/GLM 回退 Chat Completions。 + switch account.GetAPIProtocol() { + case APIProtocolChatCompletions: + return true + case APIProtocolAdaptive: + return account.Platform != PlatformDeepseek + default: + return false + } + } + return !openai_compat.ShouldUseResponsesAPI(account.Extra) +} + func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { // Determine target URL based on account type var targetURL string @@ -1035,9 +1262,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeOAuth: // OAuth accounts use ChatGPT internal API targetURL = chatgptCodexURL + case AccountTypeSetupToken: + if account.IsOpenAIOAuthLike() { + targetURL = chatgptCodexURL + } else { + targetURL = openaiPlatformAPIURL + } case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() + if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) + } if baseURL == "" { targetURL = openaiPlatformAPIURL } else { @@ -1045,13 +1281,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } default: targetURL = openaiPlatformAPIURL } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + // DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除 + // previous_response_id,避免携带状态字段被上游拒绝。 + body = normalizeDeepSeekResponsesRequestBody(account, body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -1071,7 +1311,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } // Set headers specific to OAuth accounts (ChatGPT internal API) - if account.Type == AccountTypeOAuth { + if account.UsesOpenAICodexProtocol() { // Required: set Host for ChatGPT API (must use req.Host, not Header.Set) req.Host = "chatgpt.com" if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { @@ -1088,7 +1328,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } } } - if account.Type == AccountTypeOAuth { + // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), + // 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。 + s.guardOpenAICodexTurnStateEcho(c, account, req.Header) + if account.UsesOpenAICodexProtocol() { compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id")) @@ -1105,15 +1348,15 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } compactSession := resolveOpenAICompactSessionID(c) - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), compactSession)) } else { req.Header.Set("accept", "text/event-stream") } if promptCacheKey != "" { - isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) + isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey) req.Header.Set("session_id", isolated) if !compatMessagesBridge || clientConversationID != "" { req.Header.Set("conversation_id", isolated) @@ -1131,24 +1374,22 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", customUA) } - // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 + // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。 // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + // 账号 namespace 不改变客户端身份基数,但确保 scheduler failover 后不会把 + // 同一组 Codex IDs 发送给另一份 OAuth 凭据。可选指纹收敛随后仍可覆盖这些值。 + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 - if account.Type == AccountTypeOAuth && c != nil { - if fpIDs, ok := c.Get("codex_fingerprint_ids"); ok { - if ids, ok := fpIDs.(*codexFingerprintIDs); ok { - applyCodexFingerprintHeaders(req.Header, ids) - } - } - } + applyStagedCodexFingerprintHeaders(c, account, req.Header) // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 - if account.Type == AccountTypeOAuth { + if account.UsesOpenAICodexProtocol() { enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } @@ -1159,6 +1400,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, + // 保证不被覆盖丢失)。 + applyOpenAICodexBetaFeatures(c, account, req.Header) setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable") diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index adb5f8fbc3..21c7342356 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -22,14 +22,13 @@ import ( const ( grokComposerImageBridgeVisionModel = "grok-build-0.1" grokComposerImageBridgeMaxOutputTokens = 512 - // grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers). - grokCLIVersion = xai.CLIClientVersion - grokDefaultResponsesModel = "grok-4.5" - grokRateLimitFallbackCooldown = 2 * time.Minute - grokRateLimitRepeatCooldown = 10 * time.Minute - grokRateLimitSustainedCooldown = 30 * time.Minute - grokRateLimitMaxAdaptiveCooldown = time.Hour - grokRateLimitBackoffQuietPeriod = time.Hour + grokCLIVersion = xai.CLIClientVersion + grokDefaultResponsesModel = "grok-4.5" + grokRateLimitFallbackCooldown = 2 * time.Minute + grokRateLimitRepeatCooldown = 10 * time.Minute + grokRateLimitSustainedCooldown = 30 * time.Minute + grokRateLimitMaxAdaptiveCooldown = time.Hour + grokRateLimitBackoffQuietPeriod = time.Hour ) func (s *OpenAIGatewayService) forwardGrokResponses( @@ -110,30 +109,38 @@ func (s *OpenAIGatewayService) forwardGrokResponses( return nil, buildErr } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } - // xAI can reject encrypted reasoning copied from a response produced under - // another account or cache identity. Retry once with the same routing and - // credential after removing only the rejected encrypted reasoning payload. - if attempt > 0 || resp.StatusCode != http.StatusBadRequest { + // xAI can reject encrypted reasoning or a compaction blob copied from a + // different decoder/cache context. Retry once on the same account after + // preserving visible summaries and removing only opaque replay state. + if attempt > 0 || (resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnprocessableEntity) { break } respBody := s.readUpstreamErrorBody(resp) if resp.Body != nil { _ = resp.Body.Close() } - if !isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody) { + invalidEncryptedContent := isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody) + if !invalidEncryptedContent && !isGrokCompactionReplayDecodeError(resp.StatusCode, respBody) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) break } - retryBody, changed, trimErr := trimGrokInvalidEncryptedContentRetryBody(patchedBody) + var retryBody []byte + var changed bool + var trimErr error + if invalidEncryptedContent { + retryBody, changed, trimErr = trimGrokInvalidEncryptedContentRetryBody(patchedBody) + } else { + retryBody, changed, trimErr = sanitizeGrokCompactionReplayBody(patchedBody) + } if trimErr != nil { - return nil, fmt.Errorf("prepare Grok invalid encrypted_content retry: %w", trimErr) + return nil, fmt.Errorf("prepare Grok replay decode retry: %w", trimErr) } if !changed { resp.Body = io.NopCloser(bytes.NewReader(respBody)) @@ -141,7 +148,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( } patchedBody = retryBody - slog.Info("grok_invalid_encrypted_content_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "") + slog.Info("grok_replay_decode_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "") } defer func() { _ = resp.Body.Close() }() @@ -167,17 +174,22 @@ func (s *OpenAIGatewayService) forwardGrokResponses( }) errCtx := withGrokTeamRateLimitModel(ctx, upstreamModel) s.handleGrokAccountUpstreamError(errCtx, account, resp.StatusCode, resp.Header, respBody) - // 429 / free-usage: stamp team+model cool so sibling accounts skip this model. - if resp.StatusCode == http.StatusTooManyRequests || - classifyGrokUpstreamFailure(resp.StatusCode, respBody, upstreamModel).Class == GrokFailureFreeUsage { + // Quota/rate-limit responses stamp the team+model overlay. Capacity is + // request pressure and must not hide sibling accounts. + if shouldMarkGrokTeamModelRateLimit(resp.StatusCode, respBody) { markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now())) } if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel) @@ -254,7 +266,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( } func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { - if statusCode != http.StatusBadRequest { + if statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity { return false } @@ -262,38 +274,117 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { // {"code":"invalid-argument","error":"Could not decrypt the provided encrypted_content."} // {"error":{"message":"Could not decrypt the provided encrypted_content."}} code := strings.TrimSpace(gjson.GetBytes(body, "code").String()) - message := "" errNode := gjson.GetBytes(body, "error") - switch { - case errNode.Type == gjson.String: - message = errNode.String() - case errNode.IsObject(): - message = firstNonEmpty(errNode.Get("message").String(), errNode.Get("error").String()) - if code == "" { - code = strings.TrimSpace(errNode.Get("code").String()) - } - default: - message = gjson.GetBytes(body, "message").String() - } - normalizedMessage := strings.ToLower(strings.TrimSpace(message)) - if normalizedMessage == "" { - return false + if code == "" && errNode.IsObject() { + code = strings.TrimSpace(errNode.Get("code").String()) } - if strings.EqualFold(code, "invalid_encrypted_content") { + if strings.EqualFold(code, "invalid_encrypted_content") || strings.EqualFold(code, "invalid_compaction") || strings.EqualFold(code, "compaction_decode_error") { return true } // Keep the official xAI flat-code gate so unrelated 400s are not retried. if !strings.EqualFold(code, "invalid-argument") && code != "" { return false } - // Nested OpenAI-style envelopes may omit top-level code; require decrypt text. - if code == "" && !strings.Contains(normalizedMessage, "decrypt") { + for _, candidate := range grokStructuredErrorMessageCandidates(body) { + normalizedMessage := strings.ToLower(candidate) + // Nested OpenAI-style envelopes may omit top-level code; require decrypt text. + if code == "" && !strings.Contains(normalizedMessage, "decrypt") && !strings.Contains(normalizedMessage, "decode the compaction blob") { + continue + } + if strings.Contains(normalizedMessage, "encrypted_content") && + (strings.Contains(normalizedMessage, "decrypt") || strings.Contains(normalizedMessage, "unmodified")) { + return true + } + if strings.Contains(normalizedMessage, "decode the compaction blob") { + return true + } + } + return false +} + +func isGrokCompactionReplayDecodeError(statusCode int, body []byte) bool { + if (statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity) || len(body) == 0 { return false } - return strings.Contains(normalizedMessage, "encrypted_content") && - (strings.Contains(normalizedMessage, "decrypt") || - strings.Contains(normalizedMessage, "unmodified")) + for _, candidate := range grokStructuredErrorMessageCandidates(body) { + message := strings.ToLower(candidate) + decodeSignal := strings.Contains(message, "decode") || + strings.Contains(message, "deserialize") || + strings.Contains(message, "decoder") + replaySignal := strings.Contains(message, "compaction") || + strings.Contains(message, "summary") || + strings.Contains(message, "encrypted_content") || + strings.Contains(message, "response history") + if decodeSignal && replaySignal { + return true + } + } + return false +} + +func sanitizeGrokCompactionReplayBody(body []byte) ([]byte, bool, error) { + converted, err := convertOpenAICompactInputsForGrok(body) + if err != nil { + return nil, false, fmt.Errorf("convert Grok compaction replay: %w", err) + } + var requestBody map[string]any + decoder := json.NewDecoder(bytes.NewReader(converted)) + decoder.UseNumber() + if err := decoder.Decode(&requestBody); err != nil { + return nil, false, err + } + + changed := !bytes.Equal(converted, body) + if trimOpenAIEncryptedReasoningItems(requestBody) { + changed = true + } + if dropEmptyGrokReplayReasoning(requestBody) { + changed = true + } + if previousID, _ := requestBody["previous_response_id"].(string); strings.TrimSpace(previousID) != "" && !HasFunctionCallOutput(requestBody) { + delete(requestBody, "previous_response_id") + if _, exists := requestBody["store"]; !exists { + requestBody["store"] = false + } + changed = true + } + if !changed { + return body, false, nil + } + retryBody, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return nil, false, err + } + return retryBody, true, nil +} + +func dropEmptyGrokReplayReasoning(requestBody map[string]any) bool { + items, ok := requestBody["input"].([]any) + if !ok { + return false + } + filtered := items[:0] + changed := false + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(grokStringValue(item["type"])) != "reasoning" { + filtered = append(filtered, rawItem) + continue + } + summary, _ := item["summary"].([]any) + content, hasContent := item["content"] + _, hasEncrypted := item["encrypted_content"] + if hasEncrypted || len(summary) > 0 || (hasContent && content != nil) { + filtered = append(filtered, rawItem) + continue + } + changed = true + } + if changed { + requestBody["input"] = filtered + } + return changed } // requestHasGrokEncryptedReasoning reports whether the outbound Responses body @@ -338,7 +429,7 @@ func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) { return body, false } var req map[string]any - if err := json.Unmarshal(body, &req); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &req); err != nil { return body, false } messages, ok := req["messages"].([]any) @@ -372,7 +463,7 @@ func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) { if !changed { return body, false } - out, err := json.Marshal(req) + out, err := marshalOpenAIUpstreamJSON(req) if err != nil { return body, false } @@ -388,7 +479,8 @@ func trimGrokInvalidEncryptedContentRetryBody(body []byte) ([]byte, bool, error) hasEncryptedReasoning := false for _, item := range items { - if strings.TrimSpace(item.Get("type").String()) == "reasoning" && item.Get("encrypted_content").Exists() { + if (strings.TrimSpace(item.Get("type").String()) == "reasoning" && item.Get("encrypted_content").Exists()) || + (isOpenAICompactionType(strings.TrimSpace(item.Get("type").String())) && item.Get("encrypted_content").Exists()) { hasEncryptedReasoning = true break } @@ -495,6 +587,14 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro if err != nil { return nil, err } + out, err = sanitizeGrokResponsesModelInput(out) + if err != nil { + return nil, err + } + out, err = stripRedundantGrokViewImageTool(out) + if err != nil { + return nil, err + } out, err = sanitizeGrokReasoningNullContent(out) if err != nil { return nil, err @@ -558,7 +658,7 @@ func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([ if !value.Exists() { continue } - normalized, keep := normalizeGrokReasoningEffortValue(value.String()) + normalized, keep := normalizeGrokReasoningEffortValue(value.String(), upstreamModel) if !supportsEffort || !keep { out, err = sjson.DeleteBytes(out, field) } else { @@ -569,7 +669,7 @@ func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([ } } if camel := gjson.GetBytes(out, "reasoningEffort"); camel.Exists() { - normalized, keep := normalizeGrokReasoningEffortValue(camel.String()) + normalized, keep := normalizeGrokReasoningEffortValue(camel.String(), upstreamModel) out, err = sjson.DeleteBytes(out, "reasoningEffort") if err != nil { return nil, fmt.Errorf("remove Grok reasoningEffort: %w", err) @@ -595,7 +695,7 @@ func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte if raw == "" { raw = strings.TrimSpace(gjson.GetBytes(body, "reasoningEffort").String()) } - normalized, keep := normalizeGrokReasoningEffortValue(raw) + normalized, keep := normalizeGrokReasoningEffortValue(raw, upstreamModel) keep = keep && grokSupportsReasoningEffort(upstreamModel) out := body var err error @@ -615,24 +715,34 @@ func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte return out, err } -func normalizeGrokReasoningEffortValue(raw string) (string, bool) { +func normalizeGrokReasoningEffortValue(raw, model string) (string, bool) { value := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(raw))) switch value { case "none", "low", "medium", "high": return value, true case "minimal": return "low", true - case "xhigh", "extrahigh", "max", "ultra": + case "xhigh", "extrahigh": + if grokSupportsXHighReasoningEffort(model) { + return "xhigh", true + } + return "high", true + case "max", "ultra": return "high", true default: return "", false } } +func grokSupportsXHighReasoningEffort(model string) bool { + model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) + return model == "grok-4.6" || model == "grok-4.6-latest" +} + func grokSupportsReasoningEffort(model string) bool { model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) switch model { - case xai.DefaultTextModel, "grok-4.5-latest", "grok-4.6", "grok-4.6-latest", + case "grok-4.5", "grok-4.5-latest", "grok-4.6", "grok-4.6-latest", "grok-4.3", "grok-4.3-latest", "grok-3-mini", "grok-3-mini-fast", "grok-4.20-0309-reasoning", "grok-4.20-reasoning", "grok-4.20-multi-agent-0309": @@ -652,13 +762,13 @@ func sanitizeGrokResponsesUnsupportedFields(body []byte) ([]byte, error) { } var payload any - if err := json.Unmarshal(body, &payload); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil { return nil, err } if !deleteJSONFields(payload, grokResponsesUnsupportedRecursiveFields) { return body, nil } - return json.Marshal(payload) + return marshalOpenAIUpstreamJSON(payload) } func deleteJSONFields(value any, fields map[string]struct{}) bool { @@ -757,6 +867,69 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) { return sjson.SetRawBytes(body, "tools", encodedTools) } +// An inline input_image is already visible to Grok. Keeping Codex's local +// view_image tool in the same turn can make Grok announce a tool call without +// actually calling it, so remove only that redundant automatic choice. +func stripRedundantGrokViewImageTool(body []byte) ([]byte, error) { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body, nil + } + items := input.Array() + if len(items) == 0 { + return body, nil + } + current := items[len(items)-1] + if strings.TrimSpace(current.Get("role").String()) != "user" || + !openAIJSONValueMayContainImageInput(current) { + return body, nil + } + + toolChoice := gjson.GetBytes(body, "tool_choice") + if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" { + choiceName := strings.TrimSpace(toolChoice.Get("name").String()) + if choiceName == "" { + choiceName = strings.TrimSpace(toolChoice.Get("function.name").String()) + } + if choiceName == "view_image" { + return body, nil + } + } + + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body, nil + } + filtered := make([]json.RawMessage, 0, len(tools.Array())) + changed := false + for _, tool := range tools.Array() { + if strings.TrimSpace(tool.Get("type").String()) == "function" && + strings.TrimSpace(tool.Get("name").String()) == "view_image" { + changed = true + continue + } + filtered = append(filtered, json.RawMessage(tool.Raw)) + } + if !changed { + return body, nil + } + if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" { + return body, nil + } + + if len(filtered) == 0 { + out, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + return sjson.DeleteBytes(out, "parallel_tool_calls") + } + encoded, err := json.Marshal(filtered) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(body, "tools", encoded) +} func grokResponsesToolDedupKey(tool gjson.Result) string { toolType := strings.TrimSpace(tool.Get("type").String()) if toolType != "" { @@ -772,33 +945,98 @@ func grokResponsesToolDedupKey(tool gjson.Result) string { return "json:" + normalizeCompatSeedJSON(json.RawMessage(tool.Raw)) } -// sanitizeGrokReasoningNullContent 删除 reasoning 项中的 "content": null。 -// xAI 的 untagged enum 反序列化器拒收该字段,返回 422。 +// sanitizeGrokReasoningNullContent drops explicit JSON nulls from Responses +// input items. xAI's untagged ModelInput decoder 422s on those fields. +// Compaction items stay unmodified per the compact contract. func sanitizeGrokReasoningNullContent(body []byte) ([]byte, error) { input := gjson.GetBytes(body, "input") - if !input.Exists() || !input.IsArray() { + if !input.Exists() || (!input.IsArray() && !input.IsObject()) { return body, nil } - items := input.Array() + var decoded map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return body, nil + } + rawInput, ok := decoded["input"] + if !ok { + return body, nil + } + cleaned, changed := stripExplicitNullsFromGrokInput(rawInput) + if !changed { + return body, nil + } + decoded["input"] = cleaned + out, err := marshalOpenAIUpstreamJSON(decoded) + if err != nil { + return nil, err + } + return out, nil +} + +func stripExplicitNullsFromGrokInput(value any) (any, bool) { + switch node := value.(type) { + case []any: + changed := false + for i, item := range node { + itemMap, ok := item.(map[string]any) + if !ok { + next, childChanged := stripExplicitNullsFromGrokInput(item) + if childChanged { + node[i] = next + changed = true + } + continue + } + if isOpenAICompactionType(stringValue(itemMap["type"])) { + continue + } + next, childChanged := stripExplicitNullsFromJSONObject(itemMap) + if childChanged { + node[i] = next + changed = true + } + } + return node, changed + case map[string]any: + if isOpenAICompactionType(stringValue(node["type"])) { + return node, false + } + return stripExplicitNullsFromJSONObject(node) + default: + return value, false + } +} + +func stripExplicitNullsFromJSONObject(node map[string]any) (map[string]any, bool) { + if node == nil { + return node, false + } changed := false - for i := len(items) - 1; i >= 0; i-- { - item := items[i] - if strings.TrimSpace(item.Get("type").String()) != "reasoning" { + for key, child := range node { + if child == nil { + delete(node, key) + changed = true continue } - contentResult := item.Get("content") - if contentResult.Exists() && contentResult.Type == gjson.Null { - var err error - body, err = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.content", i)) - if err != nil { - return nil, err + switch typed := child.(type) { + case map[string]any: + next, childChanged := stripExplicitNullsFromJSONObject(typed) + if childChanged { + node[key] = next + changed = true + } + case []any: + next, childChanged := stripExplicitNullsFromGrokInput(typed) + if childChanged { + node[key] = next + changed = true } - changed = true } } - _ = changed - return body, nil + return node, changed } var grokResponsesSupportedToolTypes = map[string]struct{}{ @@ -816,13 +1054,17 @@ var grokResponsesSupportedToolTypes = map[string]struct{}{ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { tools := gjson.GetBytes(body, "tools") if !tools.Exists() { - if gjson.GetBytes(body, "tool_choice").Exists() { - return sjson.DeleteBytes(body, "tool_choice") - } - return body, nil + return deleteGrokOrphanToolControls(body) } if !tools.IsArray() { - return body, nil + // xAI rejects tool_choice when tools is null/object. Drop the malformed + // collection and any orphan tool controls instead of forwarding a pair + // the Grok Responses endpoint cannot interpret. + body, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + return deleteGrokOrphanToolControls(body) } rawTools := tools.Array() @@ -834,11 +1076,11 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { raw := json.RawMessage(tool.Raw) if toolType == "function" && (!tool.Get("parameters").Exists() || tool.Get("parameters").Type == gjson.Null) { var payload map[string]any - if err := json.Unmarshal(raw, &payload); err != nil { + if err := decodeOpenAIJSONUseNumber(raw, &payload); err != nil { return nil, err } payload["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}} - encoded, err := json.Marshal(payload) + encoded, err := marshalOpenAIUpstreamJSON(payload) if err != nil { return nil, err } @@ -848,6 +1090,19 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { filteredTools = append(filteredTools, raw) } } + if !grokRawToolsContainType(filteredTools, "tool_search") { + for index, raw := range filteredTools { + if !gjson.GetBytes(raw, "defer_loading").Exists() { + continue + } + cleaned, deleteErr := sjson.DeleteBytes(raw, "defer_loading") + if deleteErr != nil { + return nil, deleteErr + } + filteredTools[index] = cleaned + toolsChanged = true + } + } var err error if len(filteredTools) != len(rawTools) || toolsChanged { @@ -865,6 +1120,9 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return nil, err } } + if len(filteredTools) == 0 { + return deleteGrokOrphanToolControls(body) + } toolChoice := gjson.GetBytes(body, "tool_choice") if !toolChoice.Exists() { @@ -879,6 +1137,29 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return body, nil } +func grokRawToolsContainType(tools []json.RawMessage, want string) bool { + for _, tool := range tools { + if strings.TrimSpace(gjson.GetBytes(tool, "type").String()) == want { + return true + } + } + return false +} + +func deleteGrokOrphanToolControls(body []byte) ([]byte, error) { + var err error + for _, field := range []string{"tool_choice", "parallel_tool_calls"} { + if !gjson.GetBytes(body, field).Exists() { + continue + } + body, err = sjson.DeleteBytes(body, field) + if err != nil { + return nil, err + } + } + return body, nil +} + func shouldDropGrokToolChoice(toolChoice gjson.Result, tools []json.RawMessage) bool { if len(tools) == 0 { return true @@ -937,7 +1218,7 @@ func (s *OpenAIGatewayService) bridgeGrokComposerImageInputs( } var reqBody map[string]any - if err := json.Unmarshal(body, &reqBody); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil { return body, OpenAIUsage{}, false, fmt.Errorf("parse grok composer image bridge request: %w", err) } @@ -1069,7 +1350,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return "", OpenAIUsage{}, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -1096,11 +1377,16 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, grokComposerImageBridgeVisionModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return "", OpenAIUsage{}, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return "", OpenAIUsage{}, fmt.Errorf("grok composer image bridge upstream error: %s", upstreamMsg) @@ -1237,6 +1523,7 @@ func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Acc if err != nil { return nil, err } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileGrok)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") @@ -1273,6 +1560,10 @@ func applyGrokCLIHeaders(headers http.Header) { } func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot) { + s.updateGrokUsageSnapshotWithRateLimit(ctx, account, snapshot, true) +} + +func (s *OpenAIGatewayService) updateGrokUsageSnapshotWithRateLimit(ctx context.Context, account *Account, snapshot *xai.QuotaSnapshot, installRateLimit bool) { if s == nil || account == nil || account.ID <= 0 || snapshot == nil { return } @@ -1320,7 +1611,7 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco // API keys retain the snapshot for observability but leave account health to // the upstream pool. Other accounts install the immediate runtime and durable // rate-limit state when the observed window is exhausted. - if hasActiveLimit && !account.IsPoolMode() { + if installRateLimit && hasActiveLimit && !account.IsPoolMode() { s.rateLimitGrok(stateCtx, account, resetAt) } else if recovery { clearGrokRateLimitAfterRecovery(stateCtx, s.accountRepo, account) @@ -1643,17 +1934,12 @@ func grokRequestedModelFromCtx(ctx context.Context) string { return strings.TrimSpace(model) } -func isGrokHeavyTransientModel(requestedModel string) bool { - model := strings.ToLower(strings.TrimSpace(xai.ResolveGrokTextResponsesModelID(requestedModel))) - return strings.Contains(model, "multi-agent") -} - func persistGrokTransientModelCooldown(account *Account, decision GrokUpstreamFailureDecision) bool { if account == nil { return false } model := strings.TrimSpace(decision.Model) - if model == "" || !isGrokHeavyTransientModel(model) { + if model == "" { return false } cooldown := decision.Cooldown @@ -1672,14 +1958,17 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex return } now := time.Now() + decision := classifyGrokUpstreamFailure(statusCode, responseBody, grokRequestedModelFromCtx(ctx)) snapshot := parseGrokQuotaSnapshot(headers, statusCode, now) stampGrokQuotaSnapshotForPlan(account, snapshot, grokRequestedModelFromCtx(ctx)) - s.updateGrokUsageSnapshot(ctx, account, snapshot) + // Capacity 429 is model pressure, not account quota exhaustion. Keep the + // snapshot for observability but do not install account-level rate limiting; + // the failover decision below applies a bounded model-scoped block instead. + s.updateGrokUsageSnapshotWithRateLimit(ctx, account, snapshot, decision.Class != GrokFailureModelCapacity) // Body-first free-usage / empty / billing / capacity must run before the // status switch so non-429 free-usage bodies still cool the account. // Pool-mode still skips durable mutation unless an explicit temp rule matches. - decision := classifyGrokUpstreamFailure(statusCode, responseBody, grokRequestedModelFromCtx(ctx)) if decision.ShouldCooldown && decision.Class != GrokFailureNone && decision.Class != GrokFailureRateLimit { if account.IsPoolMode() { // Allow configured temp rules (403) below; skip default body cools. diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index e2b8f9a477..0050d10bfa 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -607,7 +607,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -633,11 +633,16 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( }) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.StatusCode, resp.Header, respBody) if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { + retryable, retryDelay, retryDeadline, retryMax := grokSameAccountRetryMetadata(account, resp.StatusCode, respBody) return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - ResponseHeaders: resp.Header.Clone(), - RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: retryable, + RequestScopedTransient: retryable && resp.StatusCode == http.StatusTooManyRequests, + SameAccountRetryDelay: retryDelay, + SameAccountRetryDeadline: retryDeadline, + SameAccountRetryMax: retryMax, } } return s.handleChatCompletionsErrorResponse(resp, c, account, billingModel) diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go index 373907f0d7..31178122ca 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -251,7 +251,7 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing. require.NotNil(t, result) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, grokChatResponsesEndpoint, result.UpstreamEndpoint) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 9908, result.Usage.InputTokens) require.Equal(t, 12, result.Usage.OutputTokens) require.Equal(t, 9856, result.Usage.CacheReadInputTokens) @@ -352,7 +352,7 @@ func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *tes c.Request.Header.Set("X-Request-ID", "generic-"+tt.requestID) c.Set("api_key", &APIKey{ID: 7111}) - identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.5") + identity := resolveGrokCacheIdentity(c, tt.body, "", "grok-4.6") require.NotEmpty(t, identity) if index == 0 { stableIdentity = identity @@ -406,8 +406,8 @@ func TestForwardGrokChatViaResponsesTraeToolHistoryKeepsCacheRoute(t *testing.T) accountRepo: repo, } - firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") - extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6") require.NotEmpty(t, firstTurnIdentity) require.Equal(t, firstTurnIdentity, extendedTurnIdentity) @@ -463,8 +463,8 @@ func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *tes accountRepo: repo, } - firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.5") - extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + firstTurnIdentity := resolveGrokCacheIdentity(c, firstTurnBody, "", "grok-4.6") + extendedTurnIdentity := resolveGrokCacheIdentity(c, body, "", "grok-4.6") require.NotEmpty(t, firstTurnIdentity) require.Equal(t, firstTurnIdentity, extendedTurnIdentity) @@ -481,7 +481,7 @@ func TestForwardGrokChatViaResponsesTraeCompatibilityFieldsKeepCacheRoute(t *tes require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "stop").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning").Exists()) - require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "I should use lookup") + require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content").String(), "I should use lookup") require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String()) require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String()) @@ -534,7 +534,7 @@ func TestForwardGrokChatRuntimeGateFallsBackToRaw(t *testing.T) { mappedModel string wantUpstream string }{ - {name: "missing cache identity", wantUpstream: "grok-4.5"}, + {name: "missing cache identity", wantUpstream: "grok-4.6"}, {name: "non cache capable mapped model", setAPIKey: true, mappedModel: "grok-4.3", wantUpstream: "grok-4.3"}, } diff --git a/backend/internal/service/openai_gateway_grok_chat_image_tools.go b/backend/internal/service/openai_gateway_grok_chat_image_tools.go new file mode 100644 index 0000000000..2e15faa21f --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_chat_image_tools.go @@ -0,0 +1,83 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// 当前轮的内联 image_url 已可由 Grok 直接读取;若同时保留客户端本地 +// view_image,Grok 可能只预告调用工具而不继续作答,因此只移除这一冗余自动选择。 +func stripRedundantGrokChatViewImageTool(body []byte) ([]byte, error) { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body, nil + } + items := messages.Array() + if len(items) == 0 { + return body, nil + } + current := items[len(items)-1] + if strings.TrimSpace(current.Get("role").String()) != "user" || + !openAIJSONValueMayContainImageInput(current) { + return body, nil + } + + toolChoice := gjson.GetBytes(body, "tool_choice") + if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" { + choiceName := strings.TrimSpace(toolChoice.Get("function.name").String()) + if choiceName == "" { + choiceName = strings.TrimSpace(toolChoice.Get("name").String()) + } + if choiceName == "view_image" { + return body, nil + } + } + + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body, nil + } + filtered := make([]json.RawMessage, 0, len(tools.Array())) + changed := false + for _, tool := range tools.Array() { + toolName := strings.TrimSpace(tool.Get("function.name").String()) + if toolName == "" { + toolName = strings.TrimSpace(tool.Get("name").String()) + } + if strings.TrimSpace(tool.Get("type").String()) == "function" && toolName == "view_image" { + changed = true + continue + } + filtered = append(filtered, json.RawMessage(tool.Raw)) + } + if !changed { + return body, nil + } + if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" { + return body, nil + } + + if len(filtered) > 0 { + encoded, err := json.Marshal(filtered) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(body, "tools", encoded) + } + + out, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + out, err = sjson.DeleteBytes(out, "parallel_tool_calls") + if err != nil { + return nil, err + } + if strings.TrimSpace(toolChoice.String()) == "auto" { + out, err = sjson.DeleteBytes(out, "tool_choice") + } + return out, err +} diff --git a/backend/internal/service/openai_gateway_grok_inline_image_tool_test.go b/backend/internal/service/openai_gateway_grok_inline_image_tool_test.go new file mode 100644 index 0000000000..51df1823cf --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_inline_image_tool_test.go @@ -0,0 +1,184 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestForwardGrokChatViaResponsesDropsRedundantViewImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := grokChatInlineImageRequest() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7991}) + + account := grokChatBridgeTestAccount(799) + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_chat_image", 0)} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) + require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String()) + assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")") +} + +func TestForwardGrokRawChatDropsRedundantViewImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := grokChatInlineImageRequest() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + account := &Account{ + ID: 800, Platform: PlatformGrok, Type: AccountTypeAPIKey, Concurrency: 1, + Credentials: map[string]any{"api_key": "test-key", "base_url": "https://grok.example.test/v1"}, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl","object":"chat.completion","model":"grok-4.6","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`, + )), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "image_url", gjson.GetBytes(upstream.lastBody, "messages.0.content.1.type").String()) + assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(function.name==\"%s\")") +} + +func TestForwardGrokMessagesDropsRedundantViewImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{ + "model":"grok-4.6","max_tokens":32,"stream":false, + "messages":[{"role":"user","content":[ + {"type":"text","text":"What text is in this image?"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}} + ]}], + "tools":[ + {"name":"view_image","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}, + {"name":"shell_command","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}} + ], + "tool_choice":{"type":"auto"} + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7992}) + + account := healthyGrokOAuthGatewayTestAccount(801, "access-token") + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: grokMessagesSSECompletedResponse("resp_messages_image", 0)} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) + require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String()) + assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")") +} + +func TestStripRedundantGrokChatViewImageToolLeavesNonTargetRequestsByteExact(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + }{ + { + name: "current turn has no inline image", + body: `{"messages":[{"role":"user","content":"Inspect a local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`, + }, + { + name: "inline image is only historical", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]},{"role":"assistant","content":"Done"},{"role":"user","content":"Inspect another local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`, + }, + { + name: "view image is explicitly selected", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],"tools":[{"type":"function","function":{"name":"view_image"}}],"tool_choice":{"type":"function","function":{"name":"view_image"}}}`, + }, + { + name: "required with view image as the only tool", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],"tools":[{"type":"function","function":{"name":"view_image"}}],"tool_choice":"required"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := []byte(tt.body) + patched, err := stripRedundantGrokChatViewImageTool(body) + require.NoError(t, err) + require.Equal(t, body, patched) + }) + } +} + +func TestStripRedundantGrokChatViewImageToolDropsOnlyToolMetadata(t *testing.T) { + t.Parallel() + body := []byte(`{ + "messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}], + "tools":[{"type":"function","function":{"name":"view_image"}}], + "tool_choice":"auto", + "parallel_tool_calls":true + }`) + + patched, err := stripRedundantGrokChatViewImageTool(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tools").Exists()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists()) +} + +func grokChatInlineImageRequest() []byte { + return []byte(`{ + "model":"grok-4.6", + "messages":[{"role":"user","content":[ + {"type":"text","text":"What text is in this image?"}, + {"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}} + ]}], + "stream":false, + "tools":[ + {"type":"function","function":{"name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}, + {"type":"function","function":{"name":"shell_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}} + ], + "tool_choice":"auto" + }`) +} + +func assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t *testing.T, body []byte, pathTemplate string) { + t.Helper() + require.False(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "view_image", 1)).Exists(), string(body)) + require.True(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "shell_command", 1)).Exists(), string(body)) +} diff --git a/backend/internal/service/openai_gateway_grok_model_input.go b/backend/internal/service/openai_gateway_grok_model_input.go new file mode 100644 index 0000000000..6ad74dda42 --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_model_input.go @@ -0,0 +1,380 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// sanitizeGrokResponsesModelInput converts replay items from OpenAI and Chat +// shapes into the subset accepted by xAI's ModelInput decoder. Call IDs are +// assigned in a separate pass so an output can safely appear before its call. +func sanitizeGrokResponsesModelInput(body []byte) ([]byte, error) { + input := gjson.GetBytes(body, "input") + if !input.Exists() || input.Type == gjson.String { + return body, nil + } + if input.IsObject() { + var err error + body, err = sjson.SetRawBytes(body, "input", []byte("["+input.Raw+"]")) + if err != nil { + return nil, fmt.Errorf("wrap Grok Responses model input: %w", err) + } + input = gjson.GetBytes(body, "input") + } + if !input.IsArray() { + return body, nil + } + + var items []any + decoder := json.NewDecoder(bytes.NewReader([]byte(input.Raw))) + decoder.UseNumber() + if err := decoder.Decode(&items); err != nil { + return nil, fmt.Errorf("parse Grok Responses model input: %w", err) + } + callIDs, outputIDs := pairGrokReplayCallIDs(items) + filtered := make([]any, 0, len(items)) + for index, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + if text, ok := rawItem.(string); ok && strings.TrimSpace(text) != "" { + filtered = append(filtered, map[string]any{"type": "message", "role": "user", "content": text}) + } + continue + } + + itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"]))) + role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"]))) + if role == "tool" || role == "function" || isGrokReplayOutputType(itemType) { + output := firstNonNilGrokJSONValue(item["output"], item["content"], item["results"]) + filtered = append(filtered, map[string]any{ + "type": "function_call_output", + "call_id": outputIDs[index], + "output": grokModelInputString(output, "(empty)"), + }) + continue + } + + switch itemType { + case "text", "input_text", "output_text": + text := strings.TrimSpace(grokStringValue(item["text"])) + if text == "" { + continue + } + if role == "" { + role = "user" + } + item = map[string]any{"type": "message", "role": role, "content": text} + itemType = "message" + case "custom_tool_call", "tool_search_call": + originalType := itemType + item["type"] = "function_call" + itemType = "function_call" + if strings.TrimSpace(grokStringValue(item["name"])) == "" { + if originalType == "tool_search_call" { + item["name"] = "tool_search" + } + } + } + + if itemType == "" && role != "" { + itemType = "message" + item["type"] = itemType + } + if itemType == "message" { + if role == "" { + role = "user" + item["role"] = role + } + content, keep := sanitizeGrokMessageContent(item["content"]) + if !keep { + continue + } + item["content"] = content + if role == "assistant" && !grokIsCompleteOutputMessage(item) { + if text, ok := collapseGrokAssistantOutputText(content); ok { + item["content"] = text + } + delete(item, "id") + delete(item, "status") + } + } else if itemType == "reasoning" { + delete(item, "status") + if content, exists := item["content"]; exists && content == nil { + delete(item, "content") + } + } else if itemType == "function_call" { + name := strings.TrimSpace(grokStringValue(item["name"])) + arguments := item["arguments"] + if function, ok := item["function"].(map[string]any); ok { + if name == "" { + name = strings.TrimSpace(grokStringValue(function["name"])) + } + if arguments == nil { + arguments = function["arguments"] + } + } + if arguments == nil { + arguments = firstNonNilGrokJSONValue(item["input"], item["query"]) + if _, custom := item["input"]; custom { + arguments = map[string]any{"input": arguments} + } + } + if name == "" { + name = "unknown_tool" + } + item["call_id"] = callIDs[index] + item["name"] = name + item["arguments"] = grokModelInputString(arguments, "{}") + for _, field := range []string{"id", "status", "tool_call_id", "function", "input", "query", "execution"} { + delete(item, field) + } + } + if shouldStripOpenAIResponsesNonPairCallID(itemType) { + delete(item, "call_id") + } + filtered = append(filtered, item) + } + + encoded, err := json.Marshal(filtered) + if err != nil { + return nil, fmt.Errorf("serialize Grok Responses model input: %w", err) + } + updated, err := sjson.SetRawBytes(body, "input", encoded) + if err != nil { + return nil, fmt.Errorf("set Grok Responses model input: %w", err) + } + return updated, nil +} + +func pairGrokReplayCallIDs(items []any) (map[int]string, map[int]string) { + callIDs := make(map[int]string) + outputIDs := make(map[int]string) + aliases := make(map[string]string) + conflictingAliases := make(map[string]struct{}) + pendingCalls := make([]string, 0) + nextID := 0 + synthetic := func() string { + nextID++ + return fmt.Sprintf("grok_replay_call_%d", nextID) + } + registerAlias := func(alias, canonical string) { + alias = strings.TrimSpace(alias) + if alias == "" { + return + } + if _, conflict := conflictingAliases[alias]; conflict { + return + } + if existing, exists := aliases[alias]; exists && existing != canonical { + delete(aliases, alias) + conflictingAliases[alias] = struct{}{} + return + } + aliases[alias] = canonical + } + + for index, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"]))) + if !isGrokReplayCallType(itemType) { + continue + } + callID := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"]) + if callID == "" { + callID = synthetic() + } + callIDs[index] = callID + pendingCalls = append(pendingCalls, callID) + registerAlias(callID, callID) + registerAlias(grokStringValue(item["call_id"]), callID) + registerAlias(grokStringValue(item["tool_call_id"]), callID) + registerAlias(grokStringValue(item["id"]), callID) + } + + consumedCalls := make(map[string]struct{}) + consumeNextCall := func() string { + for _, callID := range pendingCalls { + if _, consumed := consumedCalls[callID]; consumed { + continue + } + consumedCalls[callID] = struct{}{} + return callID + } + return synthetic() + } + for index, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"]))) + role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"]))) + if role != "tool" && role != "function" && !isGrokReplayOutputType(itemType) { + continue + } + alias := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"], item["id"]) + _, conflict := conflictingAliases[alias] + if canonical := aliases[alias]; canonical != "" && !conflict { + outputIDs[index] = canonical + consumedCalls[canonical] = struct{}{} + } else if firstNonEmptyGrokString(item["call_id"], item["tool_call_id"]) != "" { + // Explicit call identifiers remain authoritative even when an invalid + // transcript reuses the same identifier for more than one call. + outputIDs[index] = alias + } else if conflict { + // An item ID shared by multiple calls cannot safely identify either + // one. Keep it out of the call_id namespace and leave the output orphaned. + outputIDs[index] = synthetic() + } else { + outputIDs[index] = consumeNextCall() + } + } + return callIDs, outputIDs +} + +func isGrokReplayCallType(itemType string) bool { + switch itemType { + case "function_call", "custom_tool_call", "tool_search_call": + return true + default: + return false + } +} + +func isGrokReplayOutputType(itemType string) bool { + switch itemType { + case "function_call_output", "custom_tool_call_output", "tool_search_output", "tool_search_call_output": + return true + default: + return false + } +} + +func firstNonEmptyGrokString(values ...any) string { + for _, value := range values { + if text := strings.TrimSpace(grokStringValue(value)); text != "" { + return text + } + } + return "" +} + +func firstNonNilGrokJSONValue(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func sanitizeGrokMessageContent(content any) (any, bool) { + switch value := content.(type) { + case nil: + return nil, false + case string: + return value, strings.TrimSpace(value) != "" + case []any: + filtered := make([]any, 0, len(value)) + for _, rawPart := range value { + part, ok := rawPart.(map[string]any) + if !ok { + if text, isText := rawPart.(string); !isText || strings.TrimSpace(text) != "" { + filtered = append(filtered, rawPart) + } + continue + } + switch strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) { + case "text", "input_text", "output_text": + if strings.TrimSpace(grokStringValue(part["text"])) == "" { + continue + } + case "image_url", "input_image": + if !grokContentPartHasImageURL(part) { + continue + } + } + filtered = append(filtered, part) + } + return filtered, len(filtered) > 0 + default: + return content, true + } +} + +func grokContentPartHasImageURL(part map[string]any) bool { + for _, field := range []string{"file_id", "file_data"} { + if strings.TrimSpace(grokStringValue(part[field])) != "" { + return true + } + } + raw := firstNonNilGrokJSONValue(part["image_url"], part["file_url"]) + switch value := raw.(type) { + case string: + value = strings.TrimSpace(value) + return value != "" && !isEmptyBase64DataURI(value) + case map[string]any: + url := strings.TrimSpace(grokStringValue(value["url"])) + return url != "" && !isEmptyBase64DataURI(url) + default: + return false + } +} + +func grokStringValue(value any) string { + text, _ := value.(string) + return text +} + +func grokModelInputString(value any, fallback string) string { + if text, ok := value.(string); ok { + if strings.TrimSpace(text) == "" { + return fallback + } + return text + } + if value == nil { + return fallback + } + encoded, err := json.Marshal(value) + if err != nil || len(encoded) == 0 || string(encoded) == "null" { + return fallback + } + return string(encoded) +} + +func grokIsCompleteOutputMessage(item map[string]any) bool { + return strings.TrimSpace(grokStringValue(item["id"])) != "" && + strings.TrimSpace(grokStringValue(item["status"])) != "" +} + +func collapseGrokAssistantOutputText(content any) (string, bool) { + parts, ok := content.([]any) + if !ok || len(parts) == 0 { + return "", false + } + var text strings.Builder + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok || strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) != "output_text" { + return "", false + } + value, ok := part["text"].(string) + if !ok { + return "", false + } + _, _ = text.WriteString(value) + } + if strings.TrimSpace(text.String()) == "" { + return "", false + } + return text.String(), true +} diff --git a/backend/internal/service/openai_gateway_grok_model_input_test.go b/backend/internal/service/openai_gateway_grok_model_input_test.go new file mode 100644 index 0000000000..4696c6feed --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_model_input_test.go @@ -0,0 +1,177 @@ +package service + +import ( + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestSanitizeGrokResponsesModelInputNormalizesReplayHistory(t *testing.T) { + body := []byte(`{ + "input":[ + {"role":"system","content":"rules"}, + {"type":"message","role":"assistant","id":"msg_partial","content":[{"type":"output_text","text":"done"}]}, + {"type":"reasoning","id":"rs_1","status":"completed","content":null,"summary":[{"type":"summary_text","text":"keep"}]}, + {"type":"message","role":"assistant","id":"msg_complete","status":"completed","content":[{"type":"output_text","text":"kept"}]} + ]}`) + + patched, err := sanitizeGrokResponsesModelInput(body) + require.NoError(t, err) + require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String()) + require.Equal(t, "done", gjson.GetBytes(patched, "input.1.content").String()) + require.False(t, gjson.GetBytes(patched, "input.1.id").Exists()) + require.False(t, gjson.GetBytes(patched, "input.2.status").Exists()) + require.False(t, gjson.GetBytes(patched, "input.2.content").Exists()) + require.Equal(t, "keep", gjson.GetBytes(patched, "input.2.summary.0.text").String()) + require.Equal(t, "msg_complete", gjson.GetBytes(patched, "input.3.id").String()) + require.Equal(t, "completed", gjson.GetBytes(patched, "input.3.status").String()) +} + +func TestSanitizeGrokResponsesModelInputStripsOnlyNonPairCallIDs(t *testing.T) { + body := []byte(`{"input":[ + {"type":"message","role":"user","call_id":"remove_message","content":"continue"}, + {"type":"reasoning","call_id":"remove_reasoning","summary":[]}, + {"type":"image_generation_call","call_id":"remove_image","id":"ig_1","status":"completed"}, + {"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"}, + {"type":"function_call_output","call_id":"keep_function","output":"ok"} + ]}`) + + patched, err := sanitizeGrokResponsesModelInput(body) + require.NoError(t, err) + for i := 0; i < 3; i++ { + require.False(t, gjson.GetBytes(patched, "input."+strconv.Itoa(i)+".call_id").Exists()) + } + require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.3.call_id").String()) + require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.4.call_id").String()) +} + +func TestSanitizeGrokResponsesModelInputPairsCallsInTwoPasses(t *testing.T) { + body := []byte(`{ + "input":[ + {"type":"function_call_output","output":{"before":true,"large_id":9007199254740993}}, + {"type":"function_call","function":{"name":"lookup","arguments":{"query":"x","large_id":9007199254740993}}}, + {"type":"custom_tool_call","id":"custom_1","name":"apply_patch","input":"patch"}, + {"type":"custom_tool_call_output","tool_call_id":"custom_1","output":["ok"]}, + {"type":"tool_search_call","id":"search_1","arguments":{"query":"repo"}}, + {"type":"tool_search_output","call_id":"search_1","results":{"groups":["repo"]}}, + {"role":"tool","tool_call_id":"orphan","content":{"value":1}} + ]}`) + + patched, err := sanitizeGrokResponsesModelInput(body) + require.NoError(t, err) + generated := gjson.GetBytes(patched, "input.1.call_id").String() + require.NotEmpty(t, generated) + require.Equal(t, generated, gjson.GetBytes(patched, "input.0.call_id").String()) + require.JSONEq(t, `{"before":true,"large_id":9007199254740993}`, gjson.GetBytes(patched, "input.0.output").String()) + require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.0.output").String(), "large_id").Raw) + require.Equal(t, "lookup", gjson.GetBytes(patched, "input.1.name").String()) + require.JSONEq(t, `{"query":"x","large_id":9007199254740993}`, gjson.GetBytes(patched, "input.1.arguments").String()) + require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.1.arguments").String(), "large_id").Raw) + customCallID := gjson.GetBytes(patched, "input.2.call_id").String() + require.NotEmpty(t, customCallID) + require.NotEqual(t, "custom_1", customCallID) + require.JSONEq(t, `{"input":"patch"}`, gjson.GetBytes(patched, "input.2.arguments").String()) + require.Equal(t, customCallID, gjson.GetBytes(patched, "input.3.call_id").String()) + require.JSONEq(t, `["ok"]`, gjson.GetBytes(patched, "input.3.output").String()) + require.Equal(t, "tool_search", gjson.GetBytes(patched, "input.4.name").String()) + searchCallID := gjson.GetBytes(patched, "input.4.call_id").String() + require.NotEmpty(t, searchCallID) + require.NotEqual(t, "search_1", searchCallID) + require.Equal(t, searchCallID, gjson.GetBytes(patched, "input.5.call_id").String()) + require.JSONEq(t, `{"groups":["repo"]}`, gjson.GetBytes(patched, "input.5.output").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.6.type").String()) + require.Equal(t, "orphan", gjson.GetBytes(patched, "input.6.call_id").String()) +} + +func TestSanitizeGrokResponsesModelInputMapsAllItemAliasesAndRejectsConflicts(t *testing.T) { + body := []byte(`{"input":[ + {"type":"function_call","id":"fc_alias","call_id":"call_canonical","name":"one","arguments":"{}"}, + {"type":"function_call_output","id":"fc_alias","output":"ok"}, + {"type":"function_call","id":"duplicate_item","name":"two","arguments":"{}"}, + {"type":"function_call","id":"duplicate_item","name":"three","arguments":"{}"}, + {"type":"function_call_output","id":"duplicate_item","output":"ambiguous"} + ]}`) + + patched, err := sanitizeGrokResponsesModelInput(body) + require.NoError(t, err) + require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.0.call_id").String()) + require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.1.call_id").String()) + require.False(t, gjson.GetBytes(patched, "input.0.id").Exists()) + secondCallID := gjson.GetBytes(patched, "input.2.call_id").String() + thirdCallID := gjson.GetBytes(patched, "input.3.call_id").String() + ambiguousOutputID := gjson.GetBytes(patched, "input.4.call_id").String() + require.NotEmpty(t, secondCallID) + require.NotEmpty(t, thirdCallID) + require.NotEmpty(t, ambiguousOutputID) + require.NotEqual(t, "duplicate_item", secondCallID) + require.NotEqual(t, secondCallID, thirdCallID) + require.NotEqual(t, secondCallID, ambiguousOutputID) + require.NotEqual(t, thirdCallID, ambiguousOutputID) +} + +func TestPatchGrokResponsesBodyCombinesAdditionalToolsAndDropsOrphanControls(t *testing.T) { + body := []byte(`{ + "input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup"}]},{"role":"user","content":"hi"}], + "tools":[{"type":"function","name":"lookup"}], + "tool_choice":"auto","parallel_tool_calls":true + }`) + patched, err := patchGrokResponsesBody(body, "grok-4.5") + require.NoError(t, err) + require.Equal(t, int64(1), gjson.GetBytes(patched, "tools.#").Int()) + require.Equal(t, "lookup", gjson.GetBytes(patched, "tools.0.name").String()) + require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String()) + + withoutTools, err := patchGrokResponsesBody([]byte(`{"input":"hi","tool_choice":"auto","parallel_tool_calls":true}`), "grok-4.5") + require.NoError(t, err) + require.False(t, gjson.GetBytes(withoutTools, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(withoutTools, "parallel_tool_calls").Exists()) + + for _, malformedTools := range []string{"null", `{}`, `"invalid"`} { + malformedBody := []byte(`{"input":"hi","tools":` + malformedTools + `,"tool_choice":"auto","parallel_tool_calls":true}`) + patched, err := patchGrokResponsesBody(malformedBody, "grok-4.5") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists(), string(patched)) + require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists(), string(patched)) + } +} + +func TestSanitizeGrokCompactionReplayBodyPreservesVisibleSummary(t *testing.T) { + body := []byte(`{ + "previous_response_id":"resp_stale", + "input":[{"type":"compaction","encrypted_content":"opaque","summary":[{"type":"summary_text","text":"visible history"}]},{"type":"message","role":"user","content":"continue"}] + }`) + patched, changed, err := sanitizeGrokCompactionReplayBody(body) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(patched, "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(patched, `input.#(type=="reasoning")`).Exists()) + require.Contains(t, gjson.GetBytes(patched, "input.0.content.0.text").String(), "visible history") + require.Equal(t, "continue", gjson.GetBytes(patched, "input.1.content").String()) + require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"message":"failed to deserialize compaction summary history"}}`))) + require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"detail":"could not decode compaction blob"}`))) +} + +func TestGrokStructuredErrorCandidatesDoNotShadowTopLevelMessages(t *testing.T) { + shadowedInvalidEncrypted := []byte(`{"code":"invalid-argument","error":{"type":"invalid_request_error"},"message":"Could not decrypt encrypted_content because it was modified"}`) + require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusBadRequest, shadowedInvalidEncrypted)) + require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not decode compaction history"}`))) +} + +func TestGrokDecoderCompatibility422FailsOverWithoutCooldown(t *testing.T) { + body := []byte(`{"detail":"data did not match any variant of untagged enum ModelInput at input[3]"}`) + require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, body)) + require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"message":"could not decode ModelInput at input.3"}`))) + require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not deserialize ModelInput at input[3]"}`))) + require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"Failed to deserialize the JSON body into the target type: messages[1]: data did not match any variant of untagged enum Content at line 1 column 6577"}`))) + require.True(t, (&OpenAIGatewayService{}).shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body)) + decision := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, body, "grok-4.5") + require.False(t, decision.ShouldCooldown) + require.Equal(t, GrokFailureNone, decision.Class) + + require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"message":"invalid user parameter"}}`))) + require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"messages[1].content is required"}`))) + require.False(t, isGrokDecoderCompatibilityError(http.StatusBadRequest, body)) +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 243736b1c8..d1c966ac07 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -43,6 +43,107 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String()) } +func TestPatchGrokResponsesBodyDropsRedundantViewImageForCurrentInlineImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + }{ + { + name: "top-level tools", + body: `{ + "model":"grok-4.6", + "input":[{"type":"message","role":"user","content":[ + {"type":"input_text","text":"What text is in this image?"}, + {"type":"input_image","image_url":"data:image/png;base64,AA=="} + ]}], + "tools":[ + {"type":"function","name":"view_image","parameters":{"type":"object"}}, + {"type":"function","name":"shell_command","parameters":{"type":"object"}} + ] + }`, + }, + { + name: "Responses Lite additional tools", + body: `{ + "model":"grok-4.6", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"function","name":"view_image","parameters":{"type":"object"}}, + {"type":"function","name":"shell_command","parameters":{"type":"object"}} + ]}, + {"type":"message","role":"user","content":[ + {"type":"input_text","text":"What text is in this image?"}, + {"type":"input_image","image_url":"data:image/png;base64,AA=="} + ]} + ] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.6") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, `tools.#(name=="view_image")`).Exists()) + require.Equal(t, "shell_command", gjson.GetBytes(patched, "tools.0.name").String()) + }) + } +} + +func TestPatchGrokResponsesBodyKeepsNonRedundantViewImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + }{ + { + name: "current turn has no inline image", + body: `{"input":[{"role":"user","content":[{"type":"input_text","text":"Inspect a local image"}]}],"tools":[{"type":"function","name":"view_image"}]}`, + }, + { + name: "inline image is only historical", + body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]},{"role":"assistant","content":[{"type":"output_text","text":"Done"}]},{"role":"user","content":[{"type":"input_text","text":"Inspect another local image"}]}],"tools":[{"type":"function","name":"view_image"}]}`, + }, + { + name: "view image is explicitly selected", + body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],"tools":[{"type":"function","name":"view_image"}],"tool_choice":{"type":"function","name":"view_image"}}`, + }, + { + name: "required with view image as the only tool", + body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],"tools":[{"type":"function","name":"view_image"}],"tool_choice":"required"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.6") + require.NoError(t, err) + require.Equal(t, "view_image", gjson.GetBytes(patched, "tools.0.name").String()) + }) + } +} + +func TestPatchGrokResponsesBodyDropsViewImageOnlyToolMetadata(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}], + "tools":[{"type":"function","name":"view_image"}], + "tool_choice":"auto", + "parallel_tool_calls":true + }`) + patched, err := patchGrokResponsesBody(body, "grok-4.6") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tools").Exists()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists()) +} + func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T) { t.Parallel() @@ -157,18 +258,20 @@ func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) { t.Parallel() tests := []struct { - name string - body string - path string - want string + name string + body string + upstreamModel string + path string + want string }{ - {name: "minimal nested", body: `{"input":"hi","reasoning":{"effort":"minimal"}}`, path: "reasoning.effort", want: "low"}, - {name: "xhigh snake", body: `{"input":"hi","reasoning_effort":"xhigh"}`, path: "reasoning_effort", want: "high"}, - {name: "max camel", body: `{"input":"hi","reasoningEffort":"max"}`, path: "reasoning_effort", want: "high"}, + {name: "minimal nested", body: `{"input":"hi","reasoning":{"effort":"minimal"}}`, upstreamModel: "grok-4.5", path: "reasoning.effort", want: "low"}, + {name: "xhigh stays high for 4.5", body: `{"input":"hi","reasoning_effort":"xhigh"}`, upstreamModel: "grok-4.5", path: "reasoning_effort", want: "high"}, + {name: "xhigh nested for 4.6", body: `{"input":"hi","reasoning":{"effort":"xhigh"}}`, upstreamModel: "grok-4.6", path: "reasoning.effort", want: "xhigh"}, + {name: "xhigh snake for 4.6 latest", body: `{"input":"hi","reasoning_effort":"xhigh"}`, upstreamModel: "grok-4.6-latest", path: "reasoning_effort", want: "xhigh"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.5") + patched, err := patchGrokResponsesBody([]byte(tt.body), tt.upstreamModel) require.NoError(t, err) require.Equal(t, tt.want, gjson.GetBytes(patched, tt.path).String(), string(patched)) require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) @@ -178,7 +281,7 @@ func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) { func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) { patched, err := patchGrokResponsesBody( - []byte(`{"input":"hi","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"wait","parameters":null}]}`), + []byte(`{"input":"hi","tools":[{"type":"function","name":"lookup","large_id":9007199254740993},{"type":"function","name":"wait","parameters":null}]}`), "grok-4.5", ) require.NoError(t, err) @@ -186,6 +289,7 @@ func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) { require.Equal(t, "object", tool.Get("parameters.type").String(), string(patched)) require.True(t, tool.Get("parameters.properties").IsObject(), string(patched)) } + require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "tools.0.large_id").Raw) } func TestNormalizeGrokChatReasoningEffort(t *testing.T) { @@ -194,6 +298,10 @@ func TestNormalizeGrokChatReasoningEffort(t *testing.T) { require.Equal(t, "high", gjson.GetBytes(patched, "reasoning_effort").String()) require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) + patched, err = normalizeGrokChatReasoningEffort([]byte(`{"reasoning_effort":"xhigh"}`), "grok-4.6") + require.NoError(t, err) + require.Equal(t, "xhigh", gjson.GetBytes(patched, "reasoning_effort").String()) + patched, err = normalizeGrokChatReasoningEffort([]byte(`{"reasoning_effort":"high"}`), "grok-composer-2.5-fast") require.NoError(t, err) require.False(t, gjson.GetBytes(patched, "reasoning_effort").Exists()) @@ -209,7 +317,7 @@ func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) { "tools": [ {"type": "function", "name": "kept_fn", "external_web_access": true, "parameters": {"type": "object", "properties": {"q": {"type": "string", "external_web_access": true}}}} ], - "metadata": {"external_web_access": false} + "metadata": {"external_web_access": false, "large_id": 9007199254740993} }`) patched, err := patchGrokResponsesBody(body, "grok-4.3") @@ -217,6 +325,17 @@ func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) { require.True(t, json.Valid(patched)) require.False(t, strings.Contains(string(patched), "external_web_access")) require.Equal(t, "kept_fn", gjson.GetBytes(patched, "tools.0.name").String()) + require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "metadata.large_id").Raw) +} + +func TestStripAnthropicThinkingSignaturesPreservesLargeIntegers(t *testing.T) { + body := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"work","signature":"opaque"}]}],"metadata":{"large_id":9007199254740993}}`) + + patched, changed := stripAnthropicThinkingSignatures(body) + + require.True(t, changed) + require.False(t, gjson.GetBytes(patched, "messages.0.content.0.signature").Exists()) + require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "metadata.large_id").Raw) } func TestPatchGrokResponsesBodyFlattensNamespaceTools(t *testing.T) { @@ -266,6 +385,16 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) } +func TestSanitizeGrokResponsesToolsRemovesDeferredFlagsWithToolSearch(t *testing.T) { + body := []byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true},{"type":"function","name":"apply_patch"}]}`) + + patched, err := sanitizeGrokResponsesTools(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, `tools.#(type=="tool_search")`).Exists()) + require.False(t, gjson.GetBytes(patched, `tools.#(name=="shell").defer_loading`).Exists()) + require.True(t, gjson.GetBytes(patched, `tools.#(name=="apply_patch")`).Exists()) +} + func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *testing.T) { t.Parallel() @@ -299,10 +428,10 @@ func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *test wantToolChoice: true, }, { - name: "malformed non-array tools remain untouched", + name: "malformed non-array tools are removed", body: `{"input":"hello","tools":{"type":"function","name":"lookup"},"tool_choice":"auto"}`, - wantTools: true, - wantToolChoice: true, + wantTools: false, + wantToolChoice: false, }, } @@ -699,7 +828,7 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing. require.Equal(t, "https://grok.example.test/v1/responses", req.URL.String()) require.Equal(t, "Bearer api-key", req.Header.Get("Authorization")) require.Empty(t, req.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) } func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) { @@ -1003,7 +1132,7 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) { require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.JSONEq(t, `{"model":"grok-imagine-image-quality","prompt":"draw a cat"}`, string(upstream.lastBody)) require.Equal(t, http.StatusOK, recorder.Code) require.JSONEq(t, `{"data":[{"url":"https://images.test/cat.png"}]}`, recorder.Body.String()) @@ -1070,7 +1199,7 @@ func TestForwardGrokMediaAppliesAccountModelMappingAfterEndpointNormalization(t modelMapping: map[string]any{"grok-imagine-image-quality": "vendor-image-model"}, wantRequestModel: "grok-imagine-image-quality", wantUpstream: "vendor-image-model", - wantBody: `{"model":"vendor-image-model","prompt":"draw"}`, + wantBody: `{"model":"vendor-image-model","prompt":"draw","resolution":"1k","aspect_ratio":"1:1"}`, responseBody: `{"data":[{"url":"https://images.test/mapped.png"}]}`, }, { @@ -1193,7 +1322,8 @@ func TestForwardGrokMediaImagesGenerationStripsUnsupportedSize(t *testing.T) { result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesGenerations, "", body, "application/json") require.NoError(t, err) - require.JSONEq(t, `{"model":"grok-imagine-image","prompt":"draw a cat"}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-image","prompt":"draw a cat","resolution":"1k","aspect_ratio":"1:1"}`, string(upstream.lastBody)) + require.False(t, gjson.GetBytes(upstream.lastBody, "size").Exists()) require.Equal(t, ImageBillingSize1K, result.ImageSize) require.Equal(t, "1024x1024", result.ImageInputSize) } @@ -1254,6 +1384,58 @@ func TestForwardGrokMediaImagesEditMultipartConvertsToJSON(t *testing.T) { require.Equal(t, "vendor-image-edit", result.UpstreamModel) } +func TestForwardGrokMediaImagesEditMultipartPreservesExplicitGeometry(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("model", "grok-imagine-edit")) + require.NoError(t, writer.WriteField("prompt", "edit this private image")) + require.NoError(t, writer.WriteField("size", "1024x1024")) + require.NoError(t, writer.WriteField("resolution", "2k")) + require.NoError(t, writer.WriteField("aspect_ratio", "16:9")) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", `form-data; name="image"; filename="input.png"`) + partHeader.Set("Content-Type", "image/png") + part, err := writer.CreatePart(partHeader) + require.NoError(t, err) + _, err = part.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(buf.Bytes())) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + account := &Account{ + ID: 67, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"url":"https://images.test/edited.png"}]}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesEdits, "", buf.Bytes(), writer.FormDataContentType()) + require.NoError(t, err) + require.False(t, gjson.GetBytes(upstream.lastBody, "size").Exists()) + require.Equal(t, "2k", gjson.GetBytes(upstream.lastBody, "resolution").String()) + require.Equal(t, "16:9", gjson.GetBytes(upstream.lastBody, "aspect_ratio").String()) + require.Equal(t, ImageBillingSize1K, result.ImageSize) + require.Equal(t, "1024x1024", result.ImageInputSize) +} + func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode) @@ -1464,7 +1646,7 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { require.Equal(t, http.MethodGet, upstream.lastReq.Method) require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastBody) require.Equal(t, http.StatusOK, recorder.Code) @@ -1684,10 +1866,10 @@ func TestForwardAsChatCompletionsForGrokStopFallsBackToXAIChatCompletions(t *tes require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.NotEmpty(t, upstream.lastReq.Header.Get(grokConversationIDHeader)) require.NotEqual(t, "raw-client-cache-key", upstream.lastReq.Header.Get(grokConversationIDHeader)) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) require.Equal(t, "grok", result.Model) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 1, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 1, result.Usage.CacheReadInputTokens) @@ -1800,8 +1982,8 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "resp_grok_api_key", result.ResponseID) require.Equal(t, 2, result.Usage.InputTokens) require.Equal(t, 1, result.Usage.OutputTokens) @@ -1814,6 +1996,7 @@ func TestForwardGrokResponsesRetriesInvalidEncryptedContentOnce(t *testing.T) { c, _ := gin.CreateTestContext(recorder) body := []byte(`{ "model":"grok", + "previous_response_id":"resp_valid_history", "input":[ {"type":"reasoning","summary":[{"type":"summary_text","text":"keep this summary"}],"encrypted_content":"encrypted-reasoning"}, {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]} @@ -1866,8 +2049,10 @@ func TestForwardGrokResponsesRetriesInvalidEncryptedContentOnce(t *testing.T) { require.Len(t, upstream.bodies, 2) require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.0.type").String()) + require.Equal(t, "resp_valid_history", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) require.Equal(t, "encrypted-reasoning", gjson.GetBytes(upstream.bodies[0], "input.0.encrypted_content").String()) require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[1], "input.0.type").String()) + require.Equal(t, "resp_valid_history", gjson.GetBytes(upstream.bodies[1], "previous_response_id").String()) require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.encrypted_content").Exists()) require.Equal(t, "keep this summary", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.text").String()) require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.1.type").String()) @@ -1946,6 +2131,16 @@ func TestForwardGrokResponsesInvalidEncryptedContentRecoveryDoesNotOvermatch(t * } } +func TestGrokCompactionBlobRecoveryStripsCompactionItem(t *testing.T) { + body := []byte(`{"model":"grok","input":[{"type":"compaction","id":"cmp_1","encrypted_content":"blob"},{"type":"message","role":"user","content":"hi"}]}`) + require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusUnprocessableEntity, []byte(`{"code":"invalid_compaction","error":"could not decode the compaction blob"}`))) + retry, changed, err := trimGrokInvalidEncryptedContentRetryBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(retry, "input.0.type").String()) + require.False(t, gjson.GetBytes(retry, "input.#(type==\"compaction\")").Exists()) +} + func TestForwardGrokResponsesInvalidEncryptedContentRecoveryNestedErrorShape(t *testing.T) { gin.SetMode(gin.TestMode) @@ -2064,7 +2259,7 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String()) require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) } func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) { @@ -2250,7 +2445,7 @@ func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *te require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) require.True(t, result.Stream) require.Equal(t, 6, result.Usage.InputTokens) @@ -2303,7 +2498,7 @@ func TestForwardGrokResponsesNonStreamingUsesCacheIdentityAndCachedUsage(t *test require.Equal(t, 7, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 4, result.Usage.CacheReadInputTokens) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) identity := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String() require.NotEmpty(t, identity) require.Equal(t, identity, upstream.lastReq.Header.Get(grokConversationIDHeader)) @@ -2426,7 +2621,7 @@ func TestForwardAsChatCompletionsForGrokStreamingStopFallsBackToRawXAIChatComple require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) require.NotEmpty(t, upstream.lastReq.Header.Get(grokConversationIDHeader)) require.NotEqual(t, "native-client-conversation", upstream.lastReq.Header.Get(grokConversationIDHeader)) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) require.True(t, result.Stream) require.Equal(t, 6, result.Usage.InputTokens) @@ -2441,7 +2636,7 @@ func TestForwardAsChatCompletionsForGrokComposerBridgesImageInput(t *testing.T) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - body := []byte(`{"model":"grok-composer-2.5-fast","messages":[{"role":"system","content":"You are concise."},{"role":"user","content":[{"type":"text","text":"What is shown?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}],"stream":false}`) + body := []byte(`{"model":"grok-composer-2.5-fast","messages":[{"role":"system","content":"You are concise."},{"role":"user","content":[{"type":"text","text":"What is shown?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}],"metadata":{"large_id":9007199254740993},"stream":false}`) c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") c.Set("api_key", &APIKey{ID: 5501}) @@ -2490,6 +2685,7 @@ func TestForwardAsChatCompletionsForGrokComposerBridgesImageInput(t *testing.T) require.NotEmpty(t, upstream.requests[1].Header.Get(grokConversationIDHeader)) require.Equal(t, "grok-composer-2.5-fast", gjson.GetBytes(upstream.bodies[1], "model").String()) require.False(t, strings.Contains(string(upstream.bodies[1]), "image_url")) + require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.bodies[1], "metadata.large_id").Raw) require.Contains(t, gjson.GetBytes(upstream.bodies[1], "messages.1.content").String(), "Image 1 description") require.Contains(t, gjson.GetBytes(upstream.bodies[1], "messages.1.content").String(), "A small diagram with ABC letters.") require.Equal(t, 14, result.Usage.InputTokens) @@ -2531,7 +2727,7 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) { require.Equal(t, "grok-experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Empty(t, upstream.lastReq.Header.Get("originator")) require.Empty(t, upstream.lastReq.Header.Get("version")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) @@ -2541,7 +2737,7 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) { require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.NotContains(t, string(upstream.lastBody), "chatgpt.com") require.Equal(t, "grok", result.Model) - require.Equal(t, "grok-4.5", result.UpstreamModel) + require.Equal(t, "grok-4.6", result.UpstreamModel) require.Equal(t, 5, result.Usage.InputTokens) require.Equal(t, 2, result.Usage.OutputTokens) require.Equal(t, 3, result.Usage.CacheReadInputTokens) @@ -3329,6 +3525,43 @@ func TestPatchGrokResponsesBody_StripsReasoningContentNull(t *testing.T) { require.False(t, reasoning.Get("content").Exists(), "content: null should be stripped") } +func TestPatchGrokResponsesBody_StripsNestedInputNulls(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model": "grok-4.6", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi","logprobs":null}]} + ] + }`) + patched, err := patchGrokResponsesBody(body, "grok-4.6") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "input.0.content.0.logprobs").Exists()) + require.Equal(t, "hi", gjson.GetBytes(patched, "input.0.content.0.text").String()) +} + +func TestStripExplicitNullsFromGrokInputSkipsCompaction(t *testing.T) { + t.Parallel() + + input := []any{ + map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "hi", "logprobs": nil}}}, + map[string]any{"type": "compaction", "id": "cmp_1", "encrypted_content": "blob", "status": nil}, + } + out, changed := stripExplicitNullsFromGrokInput(input) + require.True(t, changed) + items, ok := out.([]any) + require.True(t, ok) + require.Len(t, items, 2) + msg := items[0].(map[string]any) + content := msg["content"].([]any)[0].(map[string]any) + _, hasLogprobs := content["logprobs"] + require.False(t, hasLogprobs) + compaction := items[1].(map[string]any) + require.Equal(t, "blob", compaction["encrypted_content"]) + _, hasStatus := compaction["status"] + require.True(t, hasStatus, "compaction items must stay unmodified") +} + func TestPatchGrokResponsesBody_KeepsReasoningContentNonNull(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol.go b/backend/internal/service/openai_gateway_grok_tool_protocol.go index 1a8dfc35a6..9f876a1a6e 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol.go @@ -15,15 +15,28 @@ import ( const grokResponsesClientToolMappingContextKey = "grok_responses_client_tool_mapping" -func adaptGrokResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { +func adaptResponsesClientToolsForFunctionUpstream(body []byte, upstream string) ([]byte, apicompat.ResponsesClientToolMapping, error) { + return adaptResponsesClientToolsForFunctionUpstreamWithMapping( + body, + upstream, + apicompat.ResponsesClientToolMapping{}, + ) +} + +func adaptResponsesClientToolsForFunctionUpstreamWithMapping( + body []byte, + upstream string, + inherited apicompat.ResponsesClientToolMapping, + inheritedLoweredTools ...[]any, +) ([]byte, apicompat.ResponsesClientToolMapping, error) { decoder := json.NewDecoder(bytes.NewReader(body)) decoder.UseNumber() var requestBody map[string]any if err := decoder.Decode(&requestBody); err != nil { - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode Grok Responses client tools: %w", err) + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode %s Responses client tools: %w", upstream, err) } - mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + mapping, changed, err := apicompat.AdaptResponsesClientToolsWithInheritedMapping(requestBody, inherited, inheritedLoweredTools...) if err != nil { return body, apicompat.ResponsesClientToolMapping{}, err } @@ -32,15 +45,23 @@ func adaptGrokResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClie } rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) if err != nil { - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode Grok Responses client tools: %w", err) + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode %s Responses client tools: %w", upstream, err) } return rebuilt, mapping, nil } -func hasGrokResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { +func adaptGrokResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + return adaptResponsesClientToolsForFunctionUpstream(body, "Grok") +} + +func hasResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0 } +func hasGrokResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { + return hasResponsesClientToolMapping(mapping) +} + func setGrokResponsesClientToolMapping(c *gin.Context, mapping apicompat.ResponsesClientToolMapping) { if c == nil { return @@ -83,12 +104,12 @@ func restoreGrokResponsesClientToolPayload(c *gin.Context, payload []byte) ([]by return restored, err } -type grokResponsesClientToolStreamBody struct { +type responsesClientToolStreamBody struct { *io.PipeReader source io.Closer } -func (b *grokResponsesClientToolStreamBody) Close() error { +func (b *responsesClientToolStreamBody) Close() error { readerErr := b.PipeReader.Close() sourceErr := b.source.Close() if readerErr != nil { @@ -97,18 +118,26 @@ func (b *grokResponsesClientToolStreamBody) Close() error { return sourceErr } -func newGrokResponsesClientToolStreamBody( +func newResponsesClientToolStreamBody( source io.ReadCloser, mapping apicompat.ResponsesClientToolMapping, maxLineSize int, ) io.ReadCloser { reader, writer := io.Pipe() - body := &grokResponsesClientToolStreamBody{PipeReader: reader, source: source} - go transformGrokResponsesClientToolStream(source, writer, mapping, maxLineSize) + body := &responsesClientToolStreamBody{PipeReader: reader, source: source} + go transformResponsesClientToolStream(source, writer, mapping, maxLineSize) return body } -func transformGrokResponsesClientToolStream( +func newGrokResponsesClientToolStreamBody( + source io.ReadCloser, + mapping apicompat.ResponsesClientToolMapping, + maxLineSize int, +) io.ReadCloser { + return newResponsesClientToolStreamBody(source, mapping, maxLineSize) +} + +func transformResponsesClientToolStream( source io.ReadCloser, destination *io.PipeWriter, mapping apicompat.ResponsesClientToolMapping, @@ -192,7 +221,7 @@ func transformGrokResponsesClientToolStream( payloads, _, err = restorer.RestoreEvent(payload) if err != nil { _ = buffered.Flush() - _ = destination.CloseWithError(fmt.Errorf("restore Grok Responses client tool event: %w", err)) + _ = destination.CloseWithError(fmt.Errorf("restore Responses client tool event: %w", err)) return } } diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol_test.go b/backend/internal/service/openai_gateway_grok_tool_protocol_test.go index bca3d3f2bd..dd2cd5bb66 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol_test.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol_test.go @@ -65,6 +65,60 @@ func TestPatchGrokResponsesBodyWithClientToolsLowersCodexProtocol(t *testing.T) require.False(t, gjson.GetBytes(patched, "input.4.namespace").Exists()) } +func TestPatchGrokResponsesBodyWithClientToolsLowersDiscoveredToolsOutput(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model":"grok-4.5", + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","id":"tsc_fixture","call_id":"call_fixture","arguments":{"query":"subagent"},"execution":"client","status":"completed"}, + {"type":"tool_search_output","id":"tso_fixture","call_id":"call_fixture","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ]} + ]} + ] + }`) + + patched, mapping, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5") + require.NoError(t, err) + require.True(t, mapping.ToolSearch) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, mapping.NamespaceTools["multi_agent_v1__wait_agent"]) + output := gjson.GetBytes(patched, "input.1.output").String() + require.JSONEq(t, `[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ]} + ]`, output) + + require.JSONEq(t, `{ + "model":"grok-4.5", + "tools":[ + {"type":"function","name":"tool_search","description":"Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}}, + {"type":"function","name":"codex_app__load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}, + {"type":"function","name":"multi_agent_v1__spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"multi_agent_v1__wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ], + "input":[ + {"type":"function_call","call_id":"call_fixture","name":"tool_search","arguments":"{\"query\":\"subagent\"}"}, + {"type":"function_call_output","call_id":"call_fixture","output":`+string(mustMarshalJSONForTest(t, output))+`} + ] + }`, string(patched)) +} + +func mustMarshalJSONForTest(t *testing.T, value string) []byte { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return encoded +} + func TestPatchGrokResponsesBodyWithClientToolsRewritesEveryToolChoice(t *testing.T) { t.Parallel() @@ -174,6 +228,32 @@ func TestForwardGrokResponsesClientToolNameConflictReturns400(t *testing.T) { require.Empty(t, upstream.requests, "an ambiguous request must not reach xAI") } +func TestForwardGrokResponsesMalformedToolSearchOutputReturns400BeforeUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{ + "model":"grok","stream":false, + "tools":[{"type":"tool_search"}], + "input":[{"type":"tool_search_output","status":"completed"}] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := grokProtocolAPIKeyAccount(7103) + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now()) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Equal(t, "tools", gjson.Get(recorder.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "call_id") + require.Empty(t, upstream.requests, "malformed lowered output must not reach xAI") +} + func TestForwardGrokResponsesOAuthRestoresClientToolsNonStreaming(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 268aec7b5a..a494be8adf 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -15,7 +15,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -35,12 +34,27 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( defaultMappedModel string, ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) + ClearActualOpenAIUpstreamEndpoint(c) + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") + } + setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } - // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与 - // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求 - // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持 - // /v1/chat/completions 的第三方 OpenAI 兼容上游全部 400。 - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, + // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 + // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 + // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 + // openai_responses_supported=false,会先命中下方的 CC 直转分支。 + if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { + return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) + } + + // 固定 chat_completions 的 CN 账号,以及不支持 Responses 的其他 APIKey + // 账号,均将 Messages 转为 CC;固定 responses 的 CN 账号不受探针旧值覆盖。 + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -103,10 +117,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( compatContinuationDisabled := compatContinuationEnabled && s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, promptCacheKey) compatTurnState := "" - // OAuth/Plus relies on session_id + x-codex-turn-state; trimming to a + // ChatGPT/Codex credentials rely on session_id + x-codex-turn-state; trimming to a // sliding 12-message window makes the cached prefix stall at system/tools. // Keep full replay there so upstream prompt caching can grow turn by turn. - if compatReplayGuardEnabled && account.Type != AccountTypeOAuth && previousResponseID == "" && !compatContinuationDisabled { + if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() && previousResponseID == "" && !compatContinuationDisabled { compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq) } @@ -134,7 +148,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( responsesReq.PreviousResponseID = previousResponseID trimAnthropicCompatResponsesInputToLatestTurn(responsesReq) } - if compatReplayGuardEnabled && account.Type != AccountTypeOAuth { + if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() { appendOpenAICompatClaudeCodeTodoGuard(responsesReq) } @@ -169,13 +183,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } logger.L().Debug("openai messages: model mapping applied", logFields...) - // 4. Marshal Responses request body, then apply OAuth codex transform + // 4. Marshal Responses request body, then apply the ChatGPT/Codex transform. responsesBody, err := json.Marshal(responsesReq) if err != nil { return nil, fmt.Errorf("marshal responses request: %w", err) } - if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok { + if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok { var reqBody map[string]any if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) @@ -184,6 +198,11 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( SkipDefaultInstructions: true, PreserveToolCallIDs: true, }) + if codexResult.Error != nil { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", codexResult.Error.Error()) + return nil, codexResult.Error + } + setCodexToolNameReverse(c, codexResult.ToolNameReverse) forcedTemplateText := "" if s.cfg != nil { forcedTemplateText = s.cfg.Gateway.ForcedCodexInstructionsTemplate @@ -215,6 +234,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } + applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), apiKeyID) delete(reqBody, "prompt_cache_key") if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { compatTurnState = s.getOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey) @@ -296,7 +316,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } // 6. Build upstream request - if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok { + if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok { // Messages 兼容桥即使 body 未带 todo-guard/prompt_cache_key 标记(如映射到非 // gpt-5/codex 模型),也必须让 buildUpstreamRequest 走 bridge 分支,以保留 // 既有 body/session/conversation 行为。身份头在 post-build 阶段统一恢复。 @@ -317,13 +337,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // Override session_id with a deterministic UUID derived from the isolated // session key, ensuring different API keys produce different upstream sessions. if account.Platform != PlatformGrok && promptCacheKey != "" { - isolatedSessionID := generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)) + isolatedSessionID := generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)) upstreamReq.Header.Set("session_id", isolatedSessionID) if upstreamReq.Header.Get("conversation_id") != "" { upstreamReq.Header.Set("conversation_id", isolatedSessionID) } } - if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok { + if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok { // buildUpstreamRequest 保留 Messages bridge 的 body/session 兼容行为,并会先 // 清除身份头。真正发送前恢复完整 Codex 身份,避免 ChatGPT Codex 上游因缺失 // originator/OpenAI-Beta 返回 404(issue #3901)。 @@ -335,7 +355,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( zap.Bool("compat_identity_restored", true), ) } - if account.Type == AccountTypeOAuth && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" { + if account.UsesOpenAICodexProtocol() && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" { upstreamReq.Header.Del("conversation_id") } if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" { @@ -363,7 +383,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, fmt.Errorf("build grok retry request: %w", err) } } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -446,7 +466,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.Header, resp.StatusCode) } - if account.Type == AccountTypeOAuth && promptCacheKey != "" { + if account.UsesOpenAICodexProtocol() && promptCacheKey != "" { if turnState := strings.TrimSpace(resp.Header.Get("x-codex-turn-state")); turnState != "" { s.bindOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey, turnState) } @@ -480,9 +500,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if promptCacheKey != "" && anthropicDigestChain != "" { s.bindOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain, promptCacheKey, anthropicMatchedDigestChain) } - if responsesReq.ServiceTier != "" { - st := responsesReq.ServiceTier - result.ServiceTier = &st + // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 + // fast policy filter/force 之后)里的 tier。 + if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { + result.ServiceTier = tier } if responsesReq.Reasoning != nil && responsesReq.Reasoning.Effort != "" { re := responsesReq.Reasoning.Effort @@ -496,6 +517,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if handleErr == nil && account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } return result, handleErr @@ -542,8 +565,12 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai messages buffered", requestID) + finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai messages buffered", requestID) if err != nil { + var readErr *openAICompatBufferedReadError + if errors.As(err, &readErr) && readErr != nil { + return nil, readErr.cause + } return nil, err } @@ -556,6 +583,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( observer = beginUpstreamResponseModelObservation(c) } observer.Observe(finalResponse.Model, true) + observer.ObserveServiceTier(finalResponse.ServiceTier, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) @@ -595,6 +623,9 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( writeAnthropicError(c, http.StatusBadGateway, "api_error", message) return nil, fmt.Errorf("upstream response failed: %s", message) } + if strings.TrimSpace(finalResponse.Status) == "completed" { + logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, "response.completed", false) + } // When the terminal event has an empty output array, reconstruct from // accumulated delta events so the client receives the full content. @@ -617,6 +648,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: false, Duration: time.Since(startTime), } @@ -633,7 +665,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( func isOpenAICompatResponsesTerminalEvent(eventType string) bool { switch strings.TrimSpace(eventType) { - case "response.completed", "response.done", "response.incomplete", "response.failed": + case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled", "error": return true default: return false @@ -666,8 +698,40 @@ func isOpenAICompatDoneSentinelLine(line string) bool { return ok && strings.TrimSpace(payload) == "[DONE]" } +// openAICompatBufferedReadError 只标记错误发生在上游响应体读取阶段; +// 具体端点自行决定是否允许重放请求,避免共享读取器扩大重试范围。 +type openAICompatBufferedReadError struct { + cause error +} + +func (e *openAICompatBufferedReadError) Error() string { return e.cause.Error() } +func (e *openAICompatBufferedReadError) Unwrap() error { return e.cause } + +func openAICompatTerminalResponse(event *apicompat.ResponsesStreamEvent, payload []byte) *apicompat.ResponsesResponse { + if event == nil { + return nil + } + if event.Response != nil { + return event.Response + } + switch strings.TrimSpace(event.Type) { + case "response.failed", "error": + message := extractOpenAISSEErrorMessage(payload) + if message == "" { + message = "Upstream response failed" + } + return &apicompat.ResponsesResponse{ + Status: "failed", + Error: &apicompat.ResponsesError{Code: event.Code, Message: message}, + } + default: + return nil + } +} + func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( resp *http.Response, + c *gin.Context, logPrefix string, requestID string, ) (*apicompat.ResponsesResponse, OpenAIUsage, *apicompat.BufferedResponseAccumulator, error) { @@ -747,20 +811,22 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( if !ok { if frame, ok := parser.Finish(); ok { payload := openAICompatPayloadWithEventType(frame.Data, frame.EventType) + payload = string(restoreCodexToolNamesFromContext(c, []byte(payload))) var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err == nil { + s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) acc.ProcessEvent(&event) - if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil { + if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil { if event.Usage != nil { usage = copyOpenAIUsageFromResponsesUsage(event.Usage) - if event.Response.Usage == nil { - event.Response.Usage = event.Usage + if response.Usage == nil { + response.Usage = event.Usage } } - if event.Response.Usage != nil { - usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage) + if response.Usage != nil { + usage = copyOpenAIUsageFromResponsesUsage(response.Usage) } - return event.Response, usage, acc, nil + return response, usage, acc, nil } } } @@ -774,7 +840,7 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( zap.String("request_id", requestID), ) } - return nil, usage, acc, ev.err + return nil, usage, acc, &openAICompatBufferedReadError{cause: ev.err} } if isOpenAICompatDoneSentinelLine(ev.line) { @@ -785,6 +851,7 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( continue } payload := openAICompatPayloadWithEventType(frame.Data, frame.EventType) + payload = string(restoreCodexToolNamesFromContext(c, []byte(payload))) var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { @@ -794,20 +861,21 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( ) continue } + s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) acc.ProcessEvent(&event) - if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil { + if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil { if event.Usage != nil { usage = copyOpenAIUsageFromResponsesUsage(event.Usage) - if event.Response.Usage == nil { - event.Response.Usage = event.Usage + if response.Usage == nil { + response.Usage = event.Usage } } - if event.Response.Usage != nil { - usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage) + if response.Usage != nil { + usage = copyOpenAIUsageFromResponsesUsage(response.Usage) } - return event.Response, usage, acc, nil + return response, usage, acc, nil } case <-timeoutCh: @@ -848,6 +916,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( clientOutputStarted := false var streamFailoverErr error var streamNonFailoverErr error + terminalEventType := "" searchCount := 0 streamSearchSeen := make(map[string]struct{}) countSearch := account != nil && account.IsGrok() @@ -883,6 +952,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -896,6 +966,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // processDataLine handles a single "data: ..." SSE line from upstream. processDataLine := func(payload string) bool { + payload = string(restoreCodexToolNamesFromContext(c, []byte(payload))) if firstChunk { firstChunk = false ms := int(time.Since(startTime).Milliseconds()) @@ -914,11 +985,13 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( return false } observer.ObserveOpenAI([]byte(payload), event.Type) + s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) eventType := strings.TrimSpace(event.Type) isBareErrorEvent := eventType == "error" isTerminalEvent := isOpenAICompatResponsesTerminalEvent(eventType) || isBareErrorEvent if isTerminalEvent { + terminalEventType = eventType if event.Response != nil { if id := strings.TrimSpace(event.Response.ID); id != "" { responseID = id @@ -960,7 +1033,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // Once Anthropic output has started, switching accounts would splice // two model streams together. Surface a proper Anthropic error event // instead of returning a failover error that the handler cannot retry. - if !clientOutputStarted && openAIStreamFailedEventShouldFailover(payloadBytes, message) { + shouldFailover := openAIStreamFailedEventShouldFailover(payloadBytes, message) + if isBareErrorEvent { + shouldFailover = openAIStreamErrorEventShouldFailover(payloadBytes, message) + } + if !clientOutputStarted && shouldFailover { streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header) return true } @@ -1050,6 +1127,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( c.Writer.Flush() } } + logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected) return resultWithUsage(), nil } diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go new file mode 100644 index 0000000000..a4bc7b9f3a --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -0,0 +1,546 @@ +package service + +// 国产供应商(kimi/zhipu/deepseek)原生 Anthropic 端点直通路径。 +// +// 当账号 credentials["api_protocol"] = "anthropic" 时,入站 /v1/messages 请求 +// 不再做 Anthropic→CC→Anthropic 双重转换,而是零转换直通供应商的官方 +// Anthropic 兼容端点(如 https://open.bigmodel.cn/api/anthropic/v1/messages), +// 适配 Claude Code 等原生 Anthropic 客户端。转发骨架以 +// gateway_anthropic_passthrough.go 的 APIKey 透传为模板(字节级 SSE 中继 + +// usage 解析),错误/failover 语义对齐 OpenAI 网关其他路径 +// (failoverOpenAIUpstreamHTTPError / handleAnthropicErrorResponse)。 + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// forwardAnthropicViaNativeAnthropicEndpoint 将 Anthropic Messages 请求零转换 +// 直通到国产供应商的原生 Anthropic 端点。仅做模型名映射与少量 body 清洗 +// (空文本块 / web-search 历史块),协议本身不转换。 +func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + originalModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if originalModel == "" { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := gjson.GetBytes(body, "stream").Bool() + + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + if upstreamModel != originalModel { + rewritten, err := sjson.SetBytes(body, "model", upstreamModel) + if err != nil { + return nil, fmt.Errorf("rewrite model: %w", err) + } + body = rewritten + } + + // 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort; + // 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high + // (对齐 Anthropic 网关 gateway_handler 的记录语义,避免该路径长期落 NULL)。 + reasoningEffort := ApplyThinkingEnabledFallback( + NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()), + body, + billingModel, + ) + + // 与 Anthropic 平台 passthrough 相同的 pre-filter:剥离空文本块与上游 + // 无法接受的 web-search 历史块(GLM/Kimi/DeepSeek 对 server_tool_use 400)。 + body = StripEmptyTextBlocks(body) + body = FilterWebSearchHistoryBlocks(body, upstreamModel) + + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] account=%d(%s) platform=%s model=%s upstream=%s stream=%v", + account.ID, account.Name, account.Platform, originalModel, upstreamModel, clientStream) + + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if apiKey == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + targetURL, err := s.nativeAnthropicTargetURL(account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, clientStream) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, body, apiKey, targetURL) + releaseUpstreamCtx() + if err != nil { + return nil, err + } + + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { + return nil, foErr + } + // 非 failover 错误:经共享 compat handler 以 Anthropic 格式回写 + // (透传规则、ops 记录、cyber_policy 与 CC 回退路径一致)。 + return s.handleAnthropicErrorResponse(resp, c, account, billingModel) + } + + if clientStream { + return s.handleNativeAnthropicStreamingResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) + } + return s.handleNativeAnthropicBufferedResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) +} + +// nativeAnthropicTargetURL 组装国产供应商原生 Anthropic messages 端点。 +// 第三方端点保持朴素路径,不附加 ?beta=true。 +func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (string, error) { + baseURL := strings.TrimSpace(account.GetAnthropicProtocolBaseURL()) + if baseURL == "" { + return "", fmt.Errorf("account %d has no anthropic protocol base url", account.ID) + } + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return strings.TrimRight(validatedURL, "/") + "/v1/messages", nil +} + +func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + apiKey string, + targetURL string, +) (*http.Request, []byte, error) { + // 能力维度 body sanitize:与 Anthropic 平台 passthrough 相同,按 beta + // header 决定是否保留 body 中的 beta 能力字段,避免客户端"body 带字段但 + // header 忘带 token"的 bug 让第三方上游 400。 + clientBeta := "" + if c != nil && c.Request != nil { + clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") + } + if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { + clientBeta = beta + } + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed { + body = sanitized + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + + if c != nil && c.Request != nil { + for key, values := range c.Request.Header { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + if !allowedHeaders[lowerKey] { + continue + } + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } + } + } + + // 覆盖入站鉴权残留,注入上游认证(默认 x-api-key;可经 extra + // anthropic_apikey_auth_scheme 切换 Authorization: Bearer)。 + req.Header.Del("authorization") + req.Header.Del("x-api-key") + req.Header.Del("x-goog-api-key") + req.Header.Del("cookie") + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey) + + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") + } + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + } + + // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) + account.ApplyHeaderOverrides(req.Header) + + return req, body, nil +} + +// handleNativeAnthropicBufferedResponse 处理非流式原生 Anthropic 响应: +// 校验 JSON、解析 usage、透传响应头后原样回写(仅工具名反向还原)。 +func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + if s.rateLimitService != nil { + s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) + } + + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError) + if err != nil { + return nil, err + } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveAnthropic(body) + + var raw json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err, billingModel) + } + + usage := parseClaudeUsageFromResponseBody(body) + if IsForceCacheBilling(ctx) && usage.InputTokens > 0 { + body, err = classifyAnthropicResponseInputAsCacheRead(body, usage) + if err != nil { + return nil, err + } + } + + writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + body = reverseToolNamesIfPresent(c, body) + c.Data(resp.StatusCode, contentType, body) + + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: claudeUsageToOpenAIUsage(usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + Stream: false, + ReasoningEffort: reasoningEffort, + Duration: time.Since(startTime), + }, nil +} + +// handleNativeAnthropicStreamingResponse 处理流式原生 Anthropic 响应: +// 字节级 SSE 中继(逐行透传、按事件边界 flush),同时解析 usage。 +// 骨架与 handleStreamingResponseAnthropicAPIKeyPassthrough 一致。 +func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + if s.rateLimitService != nil { + s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) + } + + writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "text/event-stream" + } + c.Header("Content-Type", contentType) + if c.Writer.Header().Get("Cache-Control") == "" { + c.Header("Cache-Control", "no-cache") + } + if c.Writer.Header().Get("Connection") == "" { + c.Header("Connection", "keep-alive") + } + c.Header("X-Accel-Buffering", "no") + if v := resp.Header.Get("x-request-id"); v != "" { + c.Header("x-request-id", v) + } + + w := c.Writer + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + usage := &ClaudeUsage{} + var firstTokenMs *int + clientDisconnected := false + sawTerminalEvent := false + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + type scanEvent struct { + line string + err error + } + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + streamInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + keepaliveInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTimer *time.Timer + if keepaliveInterval > 0 { + keepaliveTimer = time.NewTimer(keepaliveInterval) + defer keepaliveTimer.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTimer != nil { + keepaliveCh = keepaliveTimer.C + } + lastDataAt := time.Now() + resetKeepaliveTimer := func() { + if keepaliveTimer == nil { + return + } + if !keepaliveTimer.Stop() { + select { + case <-keepaliveTimer.C: + default: + } + } + keepaliveTimer.Reset(keepaliveInterval) + } + inPartialEvent := false + + for { + select { + case ev, ok := <-events: + if !ok { + if !clientDisconnected { + flusher.Flush() + } + if !sawTerminalEvent { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), + fmt.Errorf("stream usage incomplete: missing terminal event") + } + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), nil + } + if ev.err != nil { + if sawTerminalEvent { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), nil + } + if clientDisconnected { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), + fmt.Errorf("stream usage incomplete after disconnect: %w", ev.err) + } + if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), + fmt.Errorf("stream usage incomplete: %w", ev.err) + } + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), ev.err + } + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), + fmt.Errorf("stream read error: %w", ev.err) + } + + line := ev.line + if data, ok := extractAnthropicSSEDataLine(line); ok { + trimmed := strings.TrimSpace(data) + observer.ObserveAnthropic([]byte(trimmed)) + if anthropicStreamEventIsTerminal("", trimmed) { + sawTerminalEvent = true + } + if firstTokenMs == nil && trimmed != "" && trimmed != "[DONE]" { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + parseSSEUsagePassthrough(data, usage) + } else { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") { + sawTerminalEvent = true + } + } + + if !clientDisconnected { + restored := string(reverseToolNamesIfPresent(c, []byte(line))) + if _, err := io.WriteString(w, restored); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) + } else if _, err := io.WriteString(w, "\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) + } else if line == "" { + // 按 SSE 事件边界刷出,减少每行 flush 带来的 syscall 开销。 + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() + inPartialEvent = false + } else { + inPartialEvent = true + } + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), + fmt.Errorf("stream usage incomplete after timeout") + } + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Stream data interval timeout: account=%d model=%s interval=%s", account.ID, upstreamModel, streamInterval) + if s.rateLimitService != nil { + s.rateLimitService.HandleStreamTimeout(ctx, account, upstreamModel) + } + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), + fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if clientDisconnected { + continue + } + if inPartialEvent { + resetKeepaliveTimer() + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + resetKeepaliveTimer() + continue + } + if _, err := fmt.Fprint(w, "event: ping\ndata: {\"type\": \"ping\"}\n\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during keepalive ping, continue draining upstream for usage: account=%d", account.ID) + continue + } + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() + } + } +} + +// nativeAnthropicStreamResult 组装流式直通结果;流中断时同样返回已观测到的 +// usage 与错误一起带出,避免上游已计量的请求漏记漏计费(对齐 issue #5148 语义)。 +func (s *OpenAIGatewayService) nativeAnthropicStreamResult( + c *gin.Context, + resp *http.Response, + usage *ClaudeUsage, + firstTokenMs *int, + clientDisconnect bool, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, +) *OpenAIForwardResult { + if usage == nil { + usage = &ClaudeUsage{} + } + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: claudeUsageToOpenAIUsage(usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + Stream: true, + ReasoningEffort: reasoningEffort, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + } +} + +// claudeUsageToOpenAIUsage 把 Anthropic 格式 usage 映射到 OpenAI 网关统一的 +// 用量结构。Anthropic 的 input_tokens 不含缓存读写,而 OpenAI 网关内部 +// 约定 InputTokens 是包含缓存明细的总输入;这里必须先合并,RecordUsage +// 才能准确拆回互斥的计费桶。 +func claudeUsageToOpenAIUsage(u *ClaudeUsage) OpenAIUsage { + if u == nil { + return OpenAIUsage{} + } + return OpenAIUsage{ + InputTokens: u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens, + OutputTokens: u.OutputTokens, + CacheCreationInputTokens: u.CacheCreationInputTokens, + CacheReadInputTokens: u.CacheReadInputTokens, + } +} diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go new file mode 100644 index 0000000000..2f68982094 --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go @@ -0,0 +1,139 @@ +//go:build unit + +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 国产供应商原生 Anthropic 直通路径(api_protocol=anthropic)的 reasoning_effort 记录。 +// 回归背景:该路径此前从不提取 Claude 协议的 output_config.effort,也不做 +// thinking-enabled 兜底,导致 kimi/zhipu/deepseek 平台分组的 /v1/messages 请求 +// usage_log.reasoning_effort 恒为 NULL。 + +func nativeAnthropicTestAccount() *Account { + return &Account{ + ID: 702, + Name: "kimi-native", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAnthropic, + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "http://anthropic.example", + }, + }, + } +} + +func nativeAnthropicBufferedResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_1","type":"message","role":"assistant","model":"k3",` + + `"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn",` + + `"usage":{"input_tokens":93,"output_tokens":16}}`, + )), + } +} + +func nativeAnthropicStreamResponse() *http.Response { + sse := `event: message_start +data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"k3","content":[],"stop_reason":null,"usage":{"input_tokens":93,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":16}} + +event: message_stop +data: {"type":"message_stop"} + +` + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + } +} + +func TestNativeAnthropicPassthroughRecordsOutputConfigEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"k3","max_tokens":32,"stream":false,` + + `"output_config":{"effort":"low"},` + + `"messages":[{"role":"user","content":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "low", *result.ReasoningEffort) +} + +func TestNativeAnthropicPassthroughThinkingEnabledFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + // 未显式传 effort,但 thinking 已启用:k3 属于 passback-required 白名单,应兜底记为 high。 + body := []byte(`{"model":"k3","max_tokens":32,"stream":false,` + + `"thinking":{"type":"enabled","budget_tokens":1024},` + + `"messages":[{"role":"user","content":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "high", *result.ReasoningEffort) +} + +func TestNativeAnthropicPassthroughStreamRecordsEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"k3","max_tokens":32,"stream":true,` + + `"output_config":{"effort":"max"},` + + `"messages":[{"role":"user","content":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicStreamResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "max", *result.ReasoningEffort) +} + +func TestNativeAnthropicPassthroughNoEffortStaysNil(t *testing.T) { + gin.SetMode(gin.TestMode) + // 既无 output_config.effort 也未启用 thinking:保持 nil,不做语义注入。 + body := []byte(`{"model":"k3","max_tokens":32,"stream":false,` + + `"messages":[{"role":"user","content":"hi"}]}`) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Nil(t, result.ReasoningEffort) +} diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index e2929be92e..31dccb969b 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -158,7 +158,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil @@ -204,7 +204,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( } } - scan := s.scanCCStream(resp, "openai messages chat fallback", requestID, startTime, emitChunk) + scan := s.scanCCStream(c, resp, "openai messages chat fallback", requestID, startTime, emitChunk) usage := scan.Usage if scan.Err != nil { @@ -218,7 +218,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -253,7 +253,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, diff --git a/backend/internal/service/openai_gateway_messages_failed_response_test.go b/backend/internal/service/openai_gateway_messages_failed_response_test.go index 51d5fcb7c9..6cb8827655 100644 --- a/backend/internal/service/openai_gateway_messages_failed_response_test.go +++ b/backend/internal/service/openai_gateway_messages_failed_response_test.go @@ -155,6 +155,33 @@ func TestForwardAsAnthropic_StreamingBareErrorBeforeOutputFailsOver(t *testing.T require.Empty(t, rec.Body.String(), "failover path must not commit downstream output") } +func TestForwardAsAnthropic_StreamingGenericBareErrorBeforeOutputIsNotHiddenByFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + ssePayload := "event: error\n" + + `data: {"type":"error","error":{"type":"server_error","code":"upstream_error","message":"mixed tools failed"}}` + "\n\n" + + "data: [DONE]\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(ssePayload)), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.ForwardAsAnthropic(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "") + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Contains(t, rec.Body.String(), "mixed tools failed") +} + func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_model_availability.go b/backend/internal/service/openai_gateway_model_availability.go index a665052e60..35adea0d72 100644 --- a/backend/internal/service/openai_gateway_model_availability.go +++ b/backend/internal/service/openai_gateway_model_availability.go @@ -34,7 +34,7 @@ func (s *OpenAIGatewayService) DiagnoseModelAvailabilityForPlatform( return ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true} } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) queryGroupID := groupID includeGrouped := false if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go new file mode 100644 index 0000000000..f78a375860 --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go @@ -0,0 +1,174 @@ +package service + +import ( + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Ollama Cloud 的 OpenAI 兼容 /v1/chat/completions 把思维放在 reasoning / thinking, +// 而 DeepSeek/OpenAI 客户端只认 reasoning_content。仅在 raw CC 直转路径上做 wire JSON +// 双向补齐,不改 CC↔Responses / Anthropic / Grok 桥。 + +func isOllamaCloudRawChatCompletionsAccount(account *Account) bool { + if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + return false + } + mode, _ := account.Extra[openai_compat.ExtraKeyResponsesMode].(string) + if openai_compat.NormalizeResponsesSupportMode(mode) != openai_compat.ResponsesSupportModeForceChatCompletions { + return false + } + if accountHasOllamaCloudUsageExtra(account) { + return true + } + if account.Credentials == nil { + return false + } + baseURL, _ := account.Credentials["base_url"].(string) + return isOllamaCloudBaseURL(baseURL) +} + +func accountHasOllamaCloudUsageExtra(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + for _, key := range []string{ + OllamaCloudUsageSessionExtraKey, + OllamaCloudUsageAutoRefreshExtraKey, + OllamaCloudUsageSnapshotExtraKey, + } { + if _, ok := account.Extra[key]; ok { + return true + } + } + return false +} + +func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte { + if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { + return body + } + body = normalizeOllamaCloudChatCompletionsRequest(body) + return clampOllamaCloudMaxTokens(account, body) +} + +func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte { + if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { + return body + } + return normalizeOllamaCloudChatCompletionsResponseJSON(body) +} + +func applyOllamaCloudRawChatCompletionsSSELine(account *Account, line string) string { + if !isOllamaCloudRawChatCompletionsAccount(account) || line == "" { + return line + } + return normalizeOllamaCloudChatCompletionsSSELine(line) +} + +func normalizeOllamaCloudChatCompletionsRequest(body []byte) []byte { + if !gjson.ValidBytes(body) { + return body + } + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + updated := body + changed := false + for i, msg := range messages.Array() { + if msg.Get("role").String() != "assistant" { + continue + } + reasoningContent, ok := jsonNonEmptyString(msg.Get("reasoning_content")) + if !ok { + continue + } + if _, has := jsonNonEmptyString(msg.Get("reasoning")); has { + continue + } + if _, has := jsonNonEmptyString(msg.Get("thinking")); has { + continue + } + next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning", reasoningContent) + if err != nil { + return body + } + updated = next + changed = true + } + if !changed { + return body + } + return updated +} + +func normalizeOllamaCloudChatCompletionsResponseJSON(body []byte) []byte { + if !gjson.ValidBytes(body) { + return body + } + choices := gjson.GetBytes(body, "choices") + if !choices.IsArray() { + return body + } + updated := body + changed := false + for i, choice := range choices.Array() { + for _, container := range []string{"message", "delta"} { + obj := choice.Get(container) + if !obj.Exists() || !obj.IsObject() { + continue + } + if obj.Get("reasoning_content").Exists() { + continue + } + src, ok := jsonNonEmptyString(obj.Get("reasoning")) + if !ok { + src, ok = jsonNonEmptyString(obj.Get("thinking")) + } + if !ok { + continue + } + next, err := sjson.SetBytes(updated, "choices."+strconv.Itoa(i)+"."+container+".reasoning_content", src) + if err != nil { + return body + } + updated = next + changed = true + } + } + if !changed { + return body + } + return updated +} + +func normalizeOllamaCloudChatCompletionsSSELine(line string) string { + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + return line + } + trimmed := strings.TrimSpace(payload) + if trimmed == "" || trimmed == "[DONE]" { + return line + } + rewritten := normalizeOllamaCloudChatCompletionsResponseJSON([]byte(payload)) + if string(rewritten) == payload { + return line + } + prefixLen := len(line) - len(payload) + if prefixLen < 0 { + return line + } + return line[:prefixLen] + string(rewritten) +} + +func jsonNonEmptyString(v gjson.Result) (string, bool) { + if v.Type != gjson.String || v.Str == "" { + return "", false + } + return v.Str, true +} diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go new file mode 100644 index 0000000000..ba8bdf714c --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go @@ -0,0 +1,269 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func ollamaCloudRawChatCompletionsTestAccount() *Account { + return &Account{ + ID: 143, + Name: "DeepSeek Ollama", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + }, + } +} + +func TestIsOllamaCloudRawChatCompletionsAccount(t *testing.T) { + t.Parallel() + + t.Run("ollama.com + force_chat_completions", func(t *testing.T) { + t.Parallel() + require.True(t, isOllamaCloudRawChatCompletionsAccount(ollamaCloudRawChatCompletionsTestAccount())) + }) + + t.Run("extra usage signal without ollama host", func(t *testing.T) { + t.Parallel() + account := rawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://example.invalid/v1" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"}, + } + require.True(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("official DeepSeek", func(t *testing.T) { + t.Parallel() + account := rawChatCompletionsTestAccount() + account.Name = "DeepSeek" + account.Credentials["base_url"] = "https://api.deepseek.com" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("OpenCode Go extra", func(t *testing.T) { + t.Parallel() + account := rawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + "opencode_go_usage_auto_refresh": true, + } + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("ollama.com without force_chat_completions", func(t *testing.T) { + t.Parallel() + account := ollamaCloudRawChatCompletionsTestAccount() + account.Extra = nil + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("anthropic ollama.com", func(t *testing.T) { + t.Parallel() + account := ollamaCloudRawChatCompletionsTestAccount() + account.Platform = PlatformAnthropic + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) +} + +func TestNormalizeOllamaCloudChatCompletionsResponseJSON(t *testing.T) { + t.Parallel() + + t.Run("copies delta.reasoning to reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"reasoning":"abc"}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.delta.reasoning").String()) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.delta.reasoning_content").String()) + }) + + t.Run("copies message.thinking to reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"message":{"thinking":"abc"}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.message.thinking").String()) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.message.reasoning_content").String()) + }) + + t.Run("does not overwrite existing reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"reasoning":"new","reasoning_content":"old"}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + require.Equal(t, "old", gjson.GetBytes(out, "choices.0.delta.reasoning_content").String()) + }) + + t.Run("empty reasoning does not open reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"reasoning":""}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + require.False(t, gjson.GetBytes(out, "choices.0.delta.reasoning_content").Exists()) + }) + + t.Run("empty thinking does not open reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"message":{"thinking":""}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + require.False(t, gjson.GetBytes(out, "choices.0.message.reasoning_content").Exists()) + }) + + t.Run("tool call chunk is unchanged", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + }) +} + +func TestNormalizeOllamaCloudChatCompletionsRequest(t *testing.T) { + t.Parallel() + + in := []byte(`{"messages":[{"role":"user","content":"weather"},{"role":"assistant","reasoning_content":"prev","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{}"}}]}]}`) + out := normalizeOllamaCloudChatCompletionsRequest(in) + require.Equal(t, "prev", gjson.GetBytes(out, "messages.1.reasoning").String()) + require.Equal(t, "prev", gjson.GetBytes(out, "messages.1.reasoning_content").String()) + require.Equal(t, "", gjson.GetBytes(out, "messages.1.content").String()) + require.Equal(t, "get_weather", gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String()) + require.False(t, gjson.GetBytes(out, "messages.0.reasoning").Exists()) +} + +func TestApplyOllamaCloudRawChatCompletionsLeavesForeignAccountsUnchanged(t *testing.T) { + t.Parallel() + + reqBody := []byte(`{"messages":[{"role":"assistant","reasoning_content":"prev","content":""}]}`) + respBody := []byte(`{"choices":[{"delta":{"reasoning":"abc"}}]}`) + sseLine := `data: {"choices":[{"delta":{"reasoning":"abc"}}]}` + + official := rawChatCompletionsTestAccount() + official.Name = "DeepSeek" + official.Credentials["base_url"] = "https://api.deepseek.com" + official.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + + opencode := rawChatCompletionsTestAccount() + opencode.Credentials["base_url"] = "https://opencode.ai/zen/go/v1" + opencode.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + "opencode_go_usage_auto_refresh": true, + } + + for _, account := range []*Account{official, opencode} { + require.Equal(t, reqBody, applyOllamaCloudRawChatCompletionsRequest(account, reqBody)) + require.Equal(t, respBody, applyOllamaCloudRawChatCompletionsResponse(account, respBody)) + require.Equal(t, sseLine, applyOllamaCloudRawChatCompletionsSSELine(account, sseLine)) + } +} + +func TestNormalizeOllamaCloudChatCompletionsSSELine(t *testing.T) { + t.Parallel() + + before := `data: {"choices":[{"delta":{"reasoning":"abc"}}]}` + after := normalizeOllamaCloudChatCompletionsSSELine(before) + require.True(t, strings.HasPrefix(after, "data: ")) + payload := strings.TrimPrefix(after, "data: ") + require.Equal(t, "abc", gjson.Get(payload, "choices.0.delta.reasoning").String()) + require.Equal(t, "abc", gjson.Get(payload, "choices.0.delta.reasoning_content").String()) + require.Equal(t, "data: [DONE]", normalizeOllamaCloudChatCompletionsSSELine("data: [DONE]")) +} + +func TestForwardAsRawChatCompletions_OllamaCloudReasoningAliasStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning":"abc"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"final answer"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8,"completion_tokens_details":{"reasoning_tokens":4}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_ollama_reasoning_stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, ollamaCloudRawChatCompletionsTestAccount(), body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.Contains(t, rec.Body.String(), `"reasoning":"abc"`) + require.Contains(t, rec.Body.String(), `"reasoning_content":"abc"`) + require.Contains(t, rec.Body.String(), `"content":"final answer"`) + require.Contains(t, rec.Body.String(), `"reasoning_tokens":4`) + require.Contains(t, rec.Body.String(), "data: [DONE]") +} + +func TestForwardAsRawChatCompletions_OllamaCloudThinkingAliasNonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"},{"role":"assistant","reasoning_content":"prev","content":""}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamJSON := `{"id":"chatcmpl_ollama","object":"chat.completion","model":"deepseek-v4-pro","choices":[{"index":0,"message":{"role":"assistant","thinking":"abc","content":"final answer"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8,"completion_tokens_details":{"reasoning_tokens":4}}}` + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_ollama_thinking_json"}}, + Body: io.NopCloser(strings.NewReader(upstreamJSON)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, ollamaCloudRawChatCompletionsTestAccount(), body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "prev", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning").String()) + require.Equal(t, "prev", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning_content").String()) + require.Equal(t, "abc", gjson.Get(rec.Body.String(), "choices.0.message.thinking").String()) + require.Equal(t, "abc", gjson.Get(rec.Body.String(), "choices.0.message.reasoning_content").String()) + require.Equal(t, "final answer", gjson.Get(rec.Body.String(), "choices.0.message.content").String()) + require.Equal(t, int64(4), gjson.Get(rec.Body.String(), "usage.completion_tokens_details.reasoning_tokens").Int()) +} diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go new file mode 100644 index 0000000000..32a508481a --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go @@ -0,0 +1,83 @@ +package service + +import ( + "encoding/json" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.uber.org/zap" +) + +// OllamaCloudMaxTokensCapExtraKey 是账号 extra 中的可选配置键,表示该 Ollama Cloud +// 账号输出 token 的 provider 级硬上限。用户可通过 admin 账号更新 API 的 extra 字段 +// 设置,覆盖默认值 ollamaCloudDefaultMaxTokensCap;0 或负数表示显式禁用 clamp。 +const OllamaCloudMaxTokensCapExtraKey = "ollama_max_tokens_cap" + +// ollamaCloudDefaultMaxTokensCap 是 Ollama Cloud 对输出 token 数的 provider 级硬上限 +// (约 65535),max_tokens 超过该值会被上游直接 400 拒绝;该上限与模型无关,不做模型过滤。 +const ollamaCloudDefaultMaxTokensCap = 65535 + +// 本文件的 clampOllamaCloudMaxTokens 被 +// applyOllamaCloudRawChatCompletionsRequest(openai_gateway_ollama_cloud_cc_reasoning.go) +// 调用,账号检测(isOllamaCloudRawChatCompletionsAccount)由调用方完成,此处不再重复判断。 + +// ollamaCloudMaxTokensCap 返回账号配置的 max_tokens 上限。账号为 nil 或 extra 中 +// 无该键时返回默认值;键值为数值类型(float64/int64/int/json.Number)时返回其整数 +// 值(0 或负数表示显式禁用 clamp);其它类型回退默认值。 +func ollamaCloudMaxTokensCap(account *Account) int64 { + if account == nil || account.Extra == nil { + return ollamaCloudDefaultMaxTokensCap + } + value, ok := account.Extra[OllamaCloudMaxTokensCapExtraKey] + if !ok { + return ollamaCloudDefaultMaxTokensCap + } + switch number := value.(type) { + case float64: + return int64(number) + case int64: + return number + case int: + return int64(number) + case json.Number: + parsed, err := number.Int64() + if err != nil { + return ollamaCloudDefaultMaxTokensCap + } + return parsed + default: + return ollamaCloudDefaultMaxTokensCap + } +} + +// clampOllamaCloudMaxTokens 把 body 中超过 cap 的 max_tokens / max_completion_tokens +// 单向压到 cap。cap <= 0 或 body 不是合法 JSON 时原样返回;sjson 出错时返回原始 body。 +// 有任一字段被 clamp 时记录一条 Debug 日志。 +func clampOllamaCloudMaxTokens(account *Account, body []byte) []byte { + cap := ollamaCloudMaxTokensCap(account) + if cap <= 0 || !gjson.ValidBytes(body) { + return body + } + clamped := false + out := body + for _, key := range []string{"max_tokens", "max_completion_tokens"} { + result := gjson.GetBytes(out, key) + if !result.Exists() || result.Type != gjson.Number || result.Int() <= cap { + continue + } + updated, err := sjson.SetBytes(out, key, cap) + if err != nil { + return body + } + out = updated + clamped = true + } + if clamped && account != nil { + logger.L().Debug("openai chat_completions raw: clamped max_tokens for ollama cloud account", + zap.Int64("account_id", account.ID), + zap.Int64("cap", cap), + ) + } + return out +} diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go new file mode 100644 index 0000000000..6bbc2d7e5f --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go @@ -0,0 +1,160 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/stretchr/testify/require" +) + +// ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。 +func ollamaMaxTokensCapTestAccount(id int64, cap any) *Account { + account := ollamaUsageAccount(id) + account.Extra[OllamaCloudMaxTokensCapExtraKey] = cap + return account +} + +func TestOllamaCloudMaxTokensClamp(t *testing.T) { + ollama := ollamaUsageAccount(101) + + tests := []struct { + name string + account *Account + body string + want string + raw bool // want 非法 JSON 时按原始字节比较 + }{ + { + name: "max_tokens above default cap is clamped", + account: ollama, + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`, + }, + { + name: "max_completion_tokens above default cap is clamped", + account: ollama, + body: `{"model":"gpt-oss:120b-cloud","max_completion_tokens":131072}`, + want: `{"model":"gpt-oss:120b-cloud","max_completion_tokens":65535}`, + }, + { + name: "both fields above cap are clamped", + account: ollama, + body: `{"model":"m","max_tokens":80000,"max_completion_tokens":90000}`, + want: `{"model":"m","max_tokens":65535,"max_completion_tokens":65535}`, + }, + { + name: "values at or below default cap are kept", + account: ollama, + body: `{"model":"m","max_tokens":65535,"max_completion_tokens":4096}`, + want: `{"model":"m","max_tokens":65535,"max_completion_tokens":4096}`, + }, + { + name: "custom extra cap is applied", + account: ollamaMaxTokensCapTestAccount(102, 32768), + body: `{"model":"m","max_tokens":50000}`, + want: `{"model":"m","max_tokens":32768}`, + }, + { + name: "extra cap zero disables clamping", + account: ollamaMaxTokensCapTestAccount(103, 0), + body: `{"model":"m","max_tokens":50000}`, + want: `{"model":"m","max_tokens":50000}`, + }, + { + name: "non-numeric extra cap falls back to default", + account: ollamaMaxTokensCapTestAccount(104, "abc"), + body: `{"model":"m","max_tokens":100000}`, + want: `{"model":"m","max_tokens":65535}`, + }, + { + name: "invalid json is left untouched", + account: ollama, + body: `{"model":"m","max_tokens":`, + want: `{"model":"m","max_tokens":`, + raw: true, + }, + { + name: "non-integer max_tokens is left untouched", + account: ollama, + body: `{"model":"m","max_tokens":1.5}`, + want: `{"model":"m","max_tokens":1.5}`, + }, + { + name: "missing max_tokens is left untouched", + account: ollama, + body: `{"model":"m"}`, + want: `{"model":"m"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := clampOllamaCloudMaxTokens(test.account, []byte(test.body)) + if test.raw { + require.Equal(t, test.want, string(got)) + return + } + require.JSONEq(t, test.want, string(got)) + }) + } +} + +func TestOllamaCloudMaxTokensCap(t *testing.T) { + require.Equal(t, int64(65535), ollamaCloudMaxTokensCap(nil)) + require.Equal(t, int64(65535), ollamaCloudMaxTokensCap(ollamaUsageAccount(201))) + + tests := []struct { + name string + cap any + want int64 + }{ + {"float64", float64(32768), 32768}, + {"int", 40000, 40000}, + {"int64", int64(50000), 50000}, + {"json.Number", json.Number("60000"), 60000}, + {"json.Number invalid", json.Number("abc"), 65535}, + {"zero disables", 0, 0}, + {"negative disables", int64(-1), -1}, + {"string falls back", "abc", 65535}, + {"bool falls back", true, 65535}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + account := ollamaMaxTokensCapTestAccount(202, test.cap) + require.Equal(t, test.want, ollamaCloudMaxTokensCap(account)) + }) + } +} + +// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 max_tokens clamp +// 已接入组合钩子 applyOllamaCloudRawChatCompletionsRequest,并遵循该钩子的账号判定门槛 +// (isOllamaCloudRawChatCompletionsAccount:platform openai + type apikey + +// force_chat_completions + ollama.com 或 Ollama usage extra)。 +func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) { + body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`) + + // Ollama Cloud 账号(ollama.com + force_chat_completions)→ clamp 到 65535。 + ollama := ollamaCloudRawChatCompletionsTestAccount() + require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, + string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + + // 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。 + official := rawChatCompletionsTestAccount() + official.Credentials["base_url"] = "https://api.deepseek.com" + official.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body)) + + // ollama.com 但无 force_chat_completions(Extra 缺键)→ 不通过钩子判定门槛,字节级不变。 + noForce := ollamaCloudRawChatCompletionsTestAccount() + noForce.Extra = nil + require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body)) + + // 空 body → 原样返回。 + require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil)) + require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{})) +} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 8c029f0aa8..e3f19f335e 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -16,8 +16,8 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -25,6 +25,104 @@ import ( "go.uber.org/zap" ) +const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping" + +func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { + return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0 +} + +func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + if !needsOpenAIResponsesClientToolAdaptation(body) { + return body, apicompat.ResponsesClientToolMapping{}, nil + } + + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var requestBody map[string]any + if err := decoder.Decode(&requestBody); err != nil { + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err) + } + var trailingValue any + if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err) + } + mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + if err != nil || !changed { + return body, mapping, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err) + } + return rebuilt, mapping, nil +} + +func needsOpenAIResponsesClientToolAdaptation(body []byte) bool { + needsAdaptation := false + var visit func(gjson.Result) bool + visit = func(value gjson.Result) bool { + if value.IsObject() { + switch strings.TrimSpace(value.Get("type").String()) { + case "custom", "custom_tool_call", "custom_tool_call_output", + "tool_search", "tool_search_call", "tool_search_output": + needsAdaptation = true + return false + } + } + if value.IsObject() || value.IsArray() { + value.ForEach(func(_, child gjson.Result) bool { + return visit(child) + }) + } + return !needsAdaptation + } + visit(gjson.ParseBytes(body)) + return needsAdaptation +} + +func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) { + if c == nil { + return apicompat.ResponsesClientToolMapping{}, false + } + value, ok := c.Get(openAIResponsesClientToolMappingContextKey) + mapping, typed := value.(apicompat.ResponsesClientToolMapping) + return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping) +} + +func setOpenAIResponsesClientToolMapping(c *gin.Context, mapping apicompat.ResponsesClientToolMapping) { + if c == nil { + return + } + if !hasOpenAIResponsesClientToolMapping(mapping) { + clearOpenAIResponsesClientToolMapping(c) + return + } + c.Set(openAIResponsesClientToolMappingContextKey, mapping) +} + +// clearOpenAIResponsesClientToolMapping removes mapping state from the prior +// forwarding attempt. Forward retries accounts on the same Gin context. +func clearOpenAIResponsesClientToolMapping(c *gin.Context) { + if c == nil { + return + } + if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists { + c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{}) + } +} + +func restoreOpenAIResponsesClientToolPayload(c *gin.Context, payload []byte) ([]byte, error) { + mapping, ok := openAIResponsesClientToolMapping(c) + if !ok || !bytes.Contains(payload, []byte(`"function_call"`)) || !json.Valid(payload) { + return payload, nil + } + restored, _, err := apicompat.RestoreResponsesClientToolPayload(payload, mapping) + return restored, err +} + func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ctx context.Context, c *gin.Context, @@ -37,9 +135,10 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( reqStream bool, startTime time.Time, ) (*OpenAIForwardResult, error) { + requestedModel := reqModel upstreamPassthroughModel := "" if isOpenAIResponsesCompactPath(c) { - compactMappedModel := resolveOpenAICompactForwardModel(account, reqModel) + compactMappedModel := s.resolveOpenAICompactFallbackModel(account, reqModel) if compactMappedModel != "" && compactMappedModel != reqModel { nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel) if setErr != nil { @@ -51,7 +150,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } } - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" { rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field" MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) @@ -80,6 +179,67 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = normalizedBody } reqStream = gjson.GetBytes(body, "stream").Bool() + + accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return nil, scopeErr + } + if accountScoped { + body = accountScopedBody + } + + stageCodexFingerprintIDs(c, nil) + // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 + // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 + // 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取 + // context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。 + if !isOpenAIResponsesCompactPath(c) { + var clientHeaders http.Header + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + if fpIDs != nil { + fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs) + if fpErr != nil { + return nil, fpErr + } + if fpChanged { + body = fpBody + } + } + stageCodexFingerprintIDs(c, fpIDs) + } + } + if account != nil && account.IsOpenAI() { + responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) + normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite) + if normalizeErr != nil { + return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr) + } + if normalized { + body = normalizedBody + } + if account.IsOpenAIOAuthLike() { + aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(body) + if aliasErr != nil { + return nil, aliasErr + } + mergeCodexToolNameReverse(c, reverse) + if aliased { + body = aliasedBody + } + } + } + + if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && + !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) { + adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) + if adaptErr != nil { + return nil, adaptErr + } + body = adaptedBody + setOpenAIResponsesClientToolMapping(c, mapping) } sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body) @@ -192,8 +352,20 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } agentTaskRecoveryTried := false + compactModelFallbackRetried := false + rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body) var resp *http.Response + var usage *OpenAIUsage + var firstTokenMs *int + responseID := "" + imageCount := 0 + var imageOutputSizes []string for { + actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if actualModel == "" { + actualModel = reqModel + } + SetOpsUpstreamModel(c, actualModel) upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) upstreamReq, buildErr := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token) releaseUpstreamCtx() @@ -202,68 +374,134 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } upstreamStart := time.Now() - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to // a failover so the handler switches to a healthy account. return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } - if resp.StatusCode < 400 { - break - } - - // Peek only to identify an invalid task. Restore the body so the existing - // passthrough error handling sees the same response after recovery fails. - probeBody := s.readUpstreamErrorBody(resp) - _ = resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(probeBody)) - if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) { - agentTaskRecoveryTried = true - expectedTaskID := account.GetCredential("task_id") - if recoveryErr := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); recoveryErr != nil { - return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr) + if resp.StatusCode >= 400 { + // Peek only to identify an invalid task. Restore the body so the existing + // passthrough error handling sees the same response after recovery fails. + probeBody := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(probeBody)) + if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, probeBody); retryErr != nil { + return nil, fmt.Errorf("normalize passthrough rejected Responses field retry body: %w", retryErr) + } else if changed && rejectedFieldRetryState.Allow(retryBody) { + body = retryBody + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying passthrough request after %s (account: %s)", reason, account.Name) + continue } - continue + if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) { + agentTaskRecoveryTried = true + expectedTaskID := account.GetCredential("task_id") + if recoveryErr := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); recoveryErr != nil { + return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr) + } + continue + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(probeBody))) + if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry( + c, account, requestedModel, body, resp.StatusCode, upstreamMsg, probeBody, compactModelFallbackRetried, + ); retry { + s.appendOpenAICompactFallbackRetryOps(c, account, resp, probeBody, upstreamMsg, true) + fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + body = retryBody + upstreamPassthroughModel = fallbackModel + compactModelFallbackRetried = true + SetOpsUpstreamModel(c, fallbackModel) + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)", + account.Name, fromModel, fallbackModel, extractUpstreamErrorCode(probeBody), + ) + continue + } + + // 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时 + // 5xx 应先触发多账号 failover,且此时尚未写入下游响应。 + // probeBody 已在上方任务探测时读取过一次,直接复用避免重复读取。 + if shouldFailoverOpenAIPassthroughResponse(account, resp.StatusCode, probeBody) { + return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) + } + return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) } - // 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时 - // 5xx 应先触发多账号 failover,且此时尚未写入下游响应。 - // probeBody 已在上方任务探测时读取过一次,直接复用避免重复读取。 - if shouldFailoverOpenAIPassthroughResponse(account, resp.StatusCode, probeBody) { - return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) + if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) { + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize) } - return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) + + // x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders + // 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离 + // failover 换号后的跨账号回带(openai_codex_turn_state.go)。 + if extractOpenAICodexTurnState(resp.Header) != "" { + s.noteOpenAICodexTurnStateProvenance(c, account) + } + + if reqStream { + result, handleErr := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) + if handleErr != nil { + if retryBody, fallbackModel, retry := s.applyOpenAIPassthroughCompactFallbackFromSignal( + c, account, requestedModel, body, handleErr, compactModelFallbackRetried, resp, + ); retry { + body = retryBody + upstreamPassthroughModel = fallbackModel + compactModelFallbackRetried = true + continue + } + if signal, ok := asOpenAICompactFallbackSignal(handleErr); ok { + _ = resp.Body.Close() + compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal) + if shouldFailoverOpenAIPassthroughResponse(account, compactResp.StatusCode, compactBody) { + return nil, s.handleFailoverErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody) + } + return nil, s.handleErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody) + } + _ = resp.Body.Close() + return nil, handleErr + } + usage = result.usage + firstTokenMs = result.firstTokenMs + responseID = strings.TrimSpace(result.responseID) + imageCount = result.imageCount + imageOutputSizes = result.imageOutputSizes + } else { + result, handleErr := s.handleNonStreamingResponsePassthrough(ctx, resp, c, account, reqModel, upstreamPassthroughModel) + if handleErr != nil { + if retryBody, fallbackModel, retry := s.applyOpenAIPassthroughCompactFallbackFromSignal( + c, account, requestedModel, body, handleErr, compactModelFallbackRetried, resp, + ); retry { + body = retryBody + upstreamPassthroughModel = fallbackModel + compactModelFallbackRetried = true + continue + } + if signal, ok := asOpenAICompactFallbackSignal(handleErr); ok { + _ = resp.Body.Close() + compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal) + if shouldFailoverOpenAIPassthroughResponse(account, compactResp.StatusCode, compactBody) { + return nil, s.handleFailoverErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody) + } + return nil, s.handleErrorResponsePassthrough(ctx, compactResp, c, account, body, compactBody) + } + _ = resp.Body.Close() + return nil, handleErr + } + usage = result.usage + responseID = strings.TrimSpace(result.responseID) + imageCount = result.imageCount + imageOutputSizes = result.imageOutputSizes + } + break } defer func() { _ = resp.Body.Close() }() - serviceTier := extractOpenAIServiceTierFromBody(body) - - var usage *OpenAIUsage - var firstTokenMs *int - responseID := "" - imageCount := 0 - var imageOutputSizes []string - if reqStream { - result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) - if err != nil { - return nil, err - } - usage = result.usage - firstTokenMs = result.firstTokenMs - responseID = strings.TrimSpace(result.responseID) - imageCount = result.imageCount - imageOutputSizes = result.imageOutputSizes - } else { - result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel) - if err != nil { - return nil, err - } - usage = result.usage - responseID = strings.TrimSpace(result.responseID) - imageCount = result.imageCount - imageOutputSizes = result.imageOutputSizes - } s.bindHTTPResponseAccount(ctx, c, account, responseID) // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 @@ -271,6 +509,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } if usage == nil { @@ -285,7 +525,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( UpstreamModel: upstreamPassthroughModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), - ServiceTier: serviceTier, + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), ReasoningEffort: reasoningEffort, Stream: reqStream, OpenAIWSMode: false, @@ -344,6 +585,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( switch account.Type { case AccountTypeOAuth: targetURL = chatgptCodexURL + case AccountTypeSetupToken: + if account.IsOpenAIOAuthLike() { + targetURL = chatgptCodexURL + } case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() if baseURL != "" { @@ -351,11 +596,14 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + // DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。 + body = normalizeDeepSeekResponsesRequestBody(account, body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -376,6 +624,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } } + // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), + // 剥离后再出站(openai_codex_turn_state.go)。 + s.guardOpenAICodexTurnStateEcho(c, account, req.Header) + // 覆盖入站鉴权残留,并注入上游认证 req.Header.Del("authorization") req.Header.Del("x-api-key") @@ -391,7 +643,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } // OAuth 透传到 ChatGPT internal API 时补齐必要头。 - if account.Type == AccountTypeOAuth { + if account.UsesOpenAICodexProtocol() { // Current Codex OAuth HTTP no longer negotiates the legacy Responses // experiment. Passthrough may receive it from an older client, so remove // only that token while preserving any independent beta negotiation. @@ -408,7 +660,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } if clientSessionID == "" { clientSessionID = resolveOpenAICompactSessionID(c) @@ -417,7 +669,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("accept", "text/event-stream") } if req.Header.Get("originator") == "" { - req.Header.Set("originator", openai.CodexDefaultOriginator) + req.Header.Set("originator", resolveCodexOutboundIdentity("").originator) } // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。 if clientSessionID == "" { @@ -427,10 +679,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( clientConversationID = promptCacheKey } if clientSessionID != "" { - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID)) + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID)) } if clientConversationID != "" { - req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID)) + req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID)) } } else if isOpenAIResponsesCompactPath(c) { // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是 @@ -445,11 +697,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("user-agent", customUA) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + + // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, + // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: + // 会话隔离之后、终态身份收口之前)。 + applyStagedCodexFingerprintHeaders(c, account, req.Header) // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 - if account.Type == AccountTypeOAuth { + if account.UsesOpenAICodexProtocol() { enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } @@ -459,6 +717,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, + // 保证不被覆盖丢失)。 + applyOpenAICodexBetaFeatures(c, account, req.Header) setOpenAICodexRoutingHintFromBody(req.Header, account, body) logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable") @@ -497,9 +758,15 @@ func stripOpenAILegacyResponsesBeta(headers http.Header) { } func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool { + if hit, _, _ := detectOpenAICyberPolicy(responseBody); hit { + return false + } if isOpenAIContextWindowError("", responseBody) { return false } + if isOpenAIHTTPUpstreamAccessStateError(statusCode, "", responseBody) { + return true + } if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) { return true } @@ -635,11 +902,13 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough( Detail: upstreamDetail, UpstreamResponseBody: upstreamDetail, }) - return newOpenAIUpstreamFailoverError( + return s.newOpenAIAccountFailoverError( + account, resp.StatusCode, resp.Header, body, upstreamMsg, + shouldDisable, !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), ) } @@ -769,6 +1038,19 @@ type openaiNonStreamingResultPassthrough struct { imageOutputSizes []string } +const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes" + +func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) { + if c == nil || written <= 0 { + return + } + current := 0 + if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok { + current, _ = value.(int) + } + c.Set(openAIStreamKeepaliveBytesKey, current+written) +} + func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { if localStarted { return true @@ -791,6 +1073,85 @@ func openAIStreamEventIsPreamble(eventType string) bool { } } +func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return true + } + + switch strings.TrimSpace(eventType) { + case "response.output_item.added": + item := gjson.GetBytes(payload, "item") + if !item.Exists() || !item.IsObject() { + return true + } + switch strings.TrimSpace(item.Get("type").String()) { + case "reasoning": + if item.Get("encrypted_content").String() != "" { + return true + } + summary := item.Get("summary") + if !summary.IsArray() { + return false + } + for _, part := range summary.Array() { + if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" { + return true + } + } + return false + case "message": + content := item.Get("content") + if !content.IsArray() { + return false + } + for _, part := range content.Array() { + switch strings.TrimSpace(part.Get("type").String()) { + case "output_text": + if part.Get("text").String() != "" { + return true + } + case "refusal": + if part.Get("refusal").String() != "" { + return true + } + default: + return true + } + } + return false + case "function_call": + return item.Get("arguments").String() != "" + case "custom_tool_call": + return item.Get("input").String() != "" + case "compaction": + return item.Get("encrypted_content").String() != "" + default: + return true + } + case "response.content_part.added": + part := gjson.GetBytes(payload, "part") + if !part.Exists() || !part.IsObject() { + return true + } + switch strings.TrimSpace(part.Get("type").String()) { + case "output_text": + return part.Get("text").String() != "" + case "refusal": + return part.Get("refusal").String() != "" + default: + return true + } + case "response.reasoning_summary_part.added": + part := gjson.GetBytes(payload, "part") + if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" { + return true + } + return part.Get("text").String() != "" + default: + return true + } +} + func openAIStreamDataStartsClientOutput(data, eventType string) bool { trimmed := strings.TrimSpace(data) if trimmed == "" { @@ -807,6 +1168,8 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool { // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。 payload := []byte(trimmed) return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload)) + case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added": + return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType) } return !openAIStreamEventIsPreamble(eventType) } @@ -885,9 +1248,34 @@ func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool { switch openAIStreamFailedEventErrorCode(payload) { case "server_is_overloaded", "slow_down": return true - default: - return false } + for _, path := range []string{"response.error.message", "error.message", "message"} { + if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) { + return true + } + } + return false +} + +func logOpenAICapacityFailoverSuppressed( + ctx context.Context, + account *Account, + path string, + upstreamRequestID string, + eventType string, +) { + fields := []zap.Field{ + zap.String("path", path), + zap.String("event_type", strings.TrimSpace(eventType)), + zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)), + } + if account != nil { + fields = append(fields, + zap.Int64("account_id", account.ID), + zap.String("platform", account.Platform), + ) + } + logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...) } // openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写 @@ -910,9 +1298,12 @@ func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) updated := payload changed := false for _, path := range []string{"response.error.code", "error.code"} { - switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) { - case "server_is_overloaded", "slow_down": - default: + parent := strings.TrimSuffix(path, ".code") + if !gjson.GetBytes(updated, parent).Exists() { + continue + } + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) + if code != "" && code != "server_is_overloaded" && code != "slow_down" { continue } next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode) @@ -936,6 +1327,12 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String())) } combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message))) + for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} { + if status := int(gjson.GetBytes(payload, path).Int()); status == http.StatusUnauthorized || + status == http.StatusForbidden || status == http.StatusTooManyRequests || status == 529 { + return status + } + } switch { case strings.Contains(combined, "rate_limit"): return http.StatusTooManyRequests @@ -945,7 +1342,9 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { return http.StatusUnauthorized case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"): return http.StatusForbidden - case code == "server_is_overloaded" || code == "slow_down": + case isOpenAIUpstreamAccessStateError(message, payload): + return http.StatusForbidden + case isOpenAIUpstreamCapacityShedEvent(payload): return http.StatusServiceUnavailable default: return http.StatusBadGateway @@ -956,15 +1355,52 @@ func openAIStreamFailureStatus(payload []byte, message string) int { if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) { return http.StatusBadGateway } - // Keep the existing 502 failover behavior for other response.failed events. - // Only rate limits need promotion because they participate in the account's - // configurable 429 same-account retry policy. - if openAIStreamFailedEventSemanticStatus(payload, message) == http.StatusTooManyRequests { - return http.StatusTooManyRequests + semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message) + switch semanticStatus { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests, 529: + return semanticStatus + case http.StatusServiceUnavailable: + if isOpenAIUpstreamCapacityShedEvent(payload) { + return semanticStatus + } } return http.StatusBadGateway } +// openAIStreamCredentialAuthFailure distinguishes credential failures from +// request/content permission denials carried inside an HTTP 200 stream. Do not +// infer credential health from free-form 403 messages: providers also use +// permission_error/forbidden/access denied for request-scoped policy failures. +func openAIStreamCredentialAuthFailure(payload []byte) bool { + if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) { + return false + } + for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} { + if int(gjson.GetBytes(payload, path).Int()) == http.StatusUnauthorized { + return true + } + } + for _, path := range []string{"response.error.type", "error.type", "type"} { + errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String())) + if errType == "authentication_error" || errType == "authentication_failed" || errType == "unauthorized_error" { + return true + } + } + for _, path := range []string{"response.error.code", "error.code", "code"} { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String())) { + case "invalid_api_key", "api_key_disabled", "unauthorized", "authentication_error", + "invalid_token", "access_token_invalid", "token_revoked", "token_invalidated", + "invalid_credentials", "credential_invalid": + return true + } + } + return false +} + +func openAIStream403AccountFailure(payload []byte, message string) bool { + return isOpenAIUpstreamAccessStateError(message, payload) || openAIStreamCredentialAuthFailure(payload) +} + func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte { if len(payload) == 0 || !gjson.ValidBytes(payload) { return payload @@ -1038,13 +1474,23 @@ func applyOpenAIStreamFailedErrorPassthroughRule( } func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool { + if hit, _, _ := detectOpenAICyberPolicy(payload); hit { + return false + } if isOpenAIContextWindowError(message, payload) { return false } + if isOpenAIUpstreamAccessStateError(message, payload) { + return true + } + semanticStatus := openAIStreamFailureStatus(payload, message) + if semanticStatus == http.StatusForbidden { + return openAIStream403AccountFailure(payload, message) + } // A response.failed event is transported over HTTP 200. Prefer its semantic // rate-limit status over a generic/invalid_request error type so it can enter // the same 429 retry policy as a regular upstream HTTP response. - if openAIStreamFailureStatus(payload, message) == http.StatusTooManyRequests { + if semanticStatus == http.StatusTooManyRequests { return true } if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) { @@ -1079,6 +1525,65 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool return true } +func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool { + if hit, _, _ := detectOpenAICyberPolicy(payload); hit { + return false + } + if isOpenAIContextWindowError(message, payload) { + return false + } + if isOpenAIUpstreamAccessStateError(message, payload) { + return true + } + switch openAIStreamFailedEventSemanticStatus(payload, message) { + case http.StatusForbidden: + return openAIStream403AccountFailure(payload, message) + case http.StatusUnauthorized, http.StatusTooManyRequests, 529: + return true + } + if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) { + return true + } + combined := strings.ToLower(strings.TrimSpace(message + " " + + gjson.GetBytes(payload, "error.message").String() + " " + + gjson.GetBytes(payload, "response.error.message").String())) + return strings.Contains(combined, "temporary") || + strings.Contains(combined, "try again") || + strings.Contains(combined, "please retry") +} + +func (s *OpenAIGatewayService) handleOpenAIStreamTerminalAccountSideEffects( + c *gin.Context, + account *Account, + payload []byte, + message string, + headers http.Header, +) (int, bool) { + statusCode := openAIStreamFailureStatus(payload, message) + switch statusCode { + case http.StatusForbidden: + if !openAIStream403AccountFailure(payload, message) { + return statusCode, false + } + fallthrough + case http.StatusUnauthorized, http.StatusTooManyRequests, 529: + ctx := context.Background() + if c != nil && c.Request != nil { + ctx = c.Request.Context() + } + accountHeaders := headers + if statusCode == http.StatusTooManyRequests { + // The enclosing HTTP response succeeded. Its quota snapshot describes + // normal account state and must not become the reset for a semantic 429 + // carried by a stream terminal event. + accountHeaders = nil + } + return statusCode, s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, accountHeaders, payload) + default: + return statusCode, false + } +} + func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool { if account == nil { return false @@ -1154,14 +1659,13 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError( if message == "" { message = "OpenAI stream disconnected before completion" } - statusCode := openAIStreamFailureStatus(payload, message) var headers http.Header if len(responseHeaders) > 0 && responseHeaders[0] != nil { headers = responseHeaders[0].Clone() } - // 流内 failed 事件承载于 HTTP 200,响应头是正常配额快照而非限流信号, - // 不写账号级限流/封禁状态;重试与切号由 failover 引擎按 - // StatusCode/RetryableOnSameAccount 决定。 + statusCode, shouldDisable := s.handleOpenAIStreamTerminalAccountSideEffects(c, account, payload, message, headers) + // 流内 failed 事件承载于 HTTP 200;使用事件的语义状态更新账号健康, + // 再由 failover 引擎按 StatusCode/RetryableOnSameAccount 决定恢复策略。 message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message) errType := "upstream_error" if statusCode == http.StatusTooManyRequests { @@ -1173,13 +1677,22 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError( "message": message, }, }) - return &UpstreamFailoverError{ - StatusCode: statusCode, - ResponseBody: body, - ResponseHeaders: headers, - RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message), - RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload), + retryableOnSameAccount := openAIStreamFailedEventRetryableOnSameAccount(account, payload, message) + // 流终止事件承载在 HTTP 200 内,外层响应头描述的是成功流状态,而不是语义上的 + // 429 事件。仅在配额分类时忽略这些头;故障转移错误仍保留它们,使 Retry-After + // 和请求 ID 能继续传递给后续处理。 + classificationHeaders := headers + if statusCode == http.StatusTooManyRequests { + classificationHeaders = nil } + failoverErr := s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, headers, classificationHeaders, payload, message, shouldDisable, retryableOnSameAccount) + if failoverErr.IsCredentialFailure() || failoverErr.RequestScopedTransient { + return failoverErr + } + // Preserve the existing generic envelope for unclassified stream failures; + // only typed access/capacity failures need the original payload downstream. + failoverErr.ResponseBody = body + return failoverErr } func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( @@ -1220,14 +1733,25 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( sawDone := false sawTerminalEvent := false sawFailedEvent := false + sawBareError := false + sawResponseFailed := false + terminalEventType := "" semanticOutputSeen := false + capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false + codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI + failureDelivered := false + suppressCurrentEvent := false + responseFailedPending := false + var bareErrorPayload []byte + bareErrorAccountSideEffectsPending := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) // pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。 pendingLines := make([]string, 0, 8) // flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。 flushPending := false + pendingSSEEventType := "" flushPendingOutput := func() { if clientDisconnected || !flushPending { return @@ -1247,6 +1771,26 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( pendingLines = pendingLines[:0] return true } + ensureResponseFailedTerminal := func() { + if !sawBareError || sawResponseFailed || failureDelivered { + return + } + if bareErrorAccountSideEffectsPending { + s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header) + bareErrorAccountSideEffectsPending = false + } + if clientDisconnected || !writePendingLines() { + return + } + if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil { + clientDisconnected = true + return + } + clientOutputStarted = true + failureDelivered = true + flushPending = true + flushPendingOutput() + } scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize @@ -1271,12 +1815,17 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( for documentScanner.Scan() { line := documentScanner.Text() + if eventType, ok := extractOpenAISSEEventLine(line); ok { + pendingSSEEventType = eventType + eventType = strings.TrimSpace(eventType) + suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed")) + } lineStartsClientOutput := false forceFlushFailedEvent := false if data, ok := extractOpenAISSEDataLine(line); ok { dataBytes := []byte(data) trimmedData := strings.TrimSpace(data) - rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + rawEventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType) observer.ObserveOpenAI(dataBytes, rawEventType) if needModelReplace && strings.Contains(data, mappedModel) { line = s.replaceModelInSSELine(line, mappedModel, originalModel) @@ -1300,20 +1849,44 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if restoreErr != nil { return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr) } + restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, rawEventType) if !bytes.Equal(restoredData, dataBytes) { dataBytes = restoredData trimmedData = strings.TrimSpace(string(restoredData)) line = "data: " + string(restoredData) } } - eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String()) - if eventType == "response.failed" { + eventType := effectiveOpenAISSEEventType(dataBytes, rawEventType) + if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" { + suppressCurrentEvent = true + } + if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI && + (eventType == "error" || eventType == "response.failed") && + openAIStreamClientOutputStarted(c, clientOutputStarted) && + isOpenAIUpstreamCapacityShedEvent(dataBytes) { + logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType) + capacityFailoverSuppressedLogged = true + } + cyberHit := false + if eventType == "response.failed" || eventType == "error" { + if codexFailureTerminal && eventType == "error" { + sawBareError = true + bareErrorPayload = append(bareErrorPayload[:0], dataBytes...) + suppressCurrentEvent = true + } else if codexFailureTerminal && eventType == "response.failed" { + sawResponseFailed = true + } + responseFailedPending = !codexFailureTerminal || eventType == "response.failed" failedMessage = extractOpenAISSEErrorMessage(dataBytes) + if failedMessage == "" { + failedMessage = "Upstream response failed" + } // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 - s.parseSSEUsageBytes(dataBytes, usage) + s.parseSSEUsageBytesWithType(dataBytes, eventType, usage) if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { + cyberHit = true MarkOpsCyberPolicy(c, CyberPolicyMark{ Code: code, Message: msg, @@ -1323,34 +1896,64 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( UpstreamOutTok: usage.OutputTokens, }) } - if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched { - // 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与 - // antigravity 先例),否则透传命中的 failed 在监控中不可见。 - s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage) - MarkResponseCommitted(c) - c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - c.JSON(status, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg) + outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted) + if !outputStarted && !cyberHit { + if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil { + return resultWithUsage(), compactErr } - if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { + } + if outputStarted && !cyberHit { + if codexFailureTerminal && eventType == "error" { + // Wait for the authoritative response.failed before mutating + // account health; EOF synthesis applies the pending effect. + bareErrorAccountSideEffectsPending = true + } else { + s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header) + bareErrorAccountSideEffectsPending = false + } + } + if !outputStarted { + shouldFailover := false + if !cyberHit { + if eventType == "error" { + shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage) + } else { + shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) + } + } + if shouldFailover { return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header) } + if !cyberHit && !sawBareError { + if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched { + // 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与 + // antigravity 先例),否则透传命中的 failed 在监控中不可见。 + s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage) + MarkResponseCommitted(c) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg) + } + } } forceFlushFailedEvent = true sawFailedEvent = true } if trimmedData == "[DONE]" { sawDone = true + terminalEventType = "[DONE]" } - if openAIStreamEventIsTerminal(trimmedData) { + if openAIStreamEventIsTerminalWithType(trimmedData, eventType) { sawTerminalEvent = true + if trimmedData != "[DONE]" { + terminalEventType = eventType + } } if responseID == "" { responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) @@ -1382,10 +1985,18 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } - s.parseSSEUsageBytes(dataBytes, usage) + s.parseSSEUsageBytesWithType(dataBytes, eventType, usage) + } + if line == "" { + pendingSSEEventType = "" + if suppressCurrentEvent { + suppressCurrentEvent = false + responseFailedPending = false + continue + } } - if !clientDisconnected { + if !clientDisconnected && !failureDelivered && !suppressCurrentEvent { if !clientOutputStarted && !lineStartsClientOutput { pendingLines = append(pendingLines, line) continue @@ -1406,7 +2017,12 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } } } + if line == "" && responseFailedPending { + responseFailedPending = false + failureDelivered = true + } } + ensureResponseFailedTerminal() if err := documentScanner.Err(); err != nil { if (sawDone || sawTerminalEvent) && !sawFailedEvent { s.clearOpenAIProxyStreamDisconnect(account) @@ -1461,6 +2077,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if (sawDone || sawTerminalEvent) && !sawFailedEvent { s.clearOpenAIProxyStreamDisconnect(account) } + logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected) return resultWithUsage(), nil } @@ -1469,6 +2086,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( ctx context.Context, resp *http.Response, c *gin.Context, + account *Account, originalModel string, mappedModel string, ) (*openaiNonStreamingResultPassthrough, error) { @@ -1491,7 +2109,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( // stream=false was requested. Without this conversion the client would // receive raw SSE text or a terminal event with empty output. if isEventStreamResponse(resp.Header) { - return s.handlePassthroughSSEToJSON(resp, c, body, originalModel, mappedModel) + return s.handlePassthroughSSEToJSON(resp, c, account, body, originalModel, mappedModel) } usage := &OpenAIUsage{} @@ -1506,6 +2124,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( // 兜底:尝试从 SSE 文本中解析 usage usage = s.parseSSEUsageFromBody(string(body)) } + logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false) writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -1520,6 +2139,11 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( if err != nil { return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err) } + body = restoreCodexToolNamesFromContext(c, body) + body, err = restoreOpenAIResponsesClientToolPayload(c, body) + if err != nil { + return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err) + } if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) { c.Data(resp.StatusCode, contentType, body) } @@ -1536,11 +2160,22 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( // response for the passthrough path. It mirrors handleSSEToJSON while // preserving passthrough payloads, except compact-only model remapping may // rewrite model fields back to the original requested model. -func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) { +func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) { bodyText := string(body) + terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) + if terminalOK && (terminalType == "response.failed" || terminalType == "error") { + msg := extractOpenAISSEErrorMessage(terminalPayload) + if msg == "" { + msg = "Upstream compact response failed" + } + if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil { + return nil, compactErr + } + return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) + } finalResponse, ok := extractCodexFinalResponse(bodyText) - usage := &OpenAIUsage{} + usage := s.parseSSEUsageFromBody(bodyText) if ok { if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { *usage = parsedUsage @@ -1565,17 +2200,9 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c if restoreErr != nil { return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr) } + restoredBody = restoreCodexToolNamesFromContext(c, restoredBody) body = restoredBody } else { - terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) - if terminalOK && terminalType == "response.failed" { - msg := extractOpenAISSEErrorMessage(terminalPayload) - if msg == "" { - msg = "Upstream compact response failed" - } - return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) - } - usage = s.parseSSEUsageFromBody(bodyText) if originalModel != "" && mappedModel != "" && originalModel != mappedModel { bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) } @@ -1583,6 +2210,7 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false) contentType := "application/json; charset=utf-8" if !ok { @@ -1650,4 +2278,13 @@ func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, fil dst.Add(key, v) } } + + // x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。 + // 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留 + // 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。 + turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader) + dst.Del(turnStateKey) + for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) { + dst.Add(turnStateKey, v) + } } diff --git a/backend/internal/service/openai_gateway_passthrough_flush_test.go b/backend/internal/service/openai_gateway_passthrough_flush_test.go index b36a037806..9ca59bee22 100644 --- a/backend/internal/service/openai_gateway_passthrough_flush_test.go +++ b/backend/internal/service/openai_gateway_passthrough_flush_test.go @@ -191,6 +191,48 @@ func TestOpenAIStreamingPassthroughNonRetryableFailedBeforeOutputFlushesAtBounda require.Zero(t, result.usage.OutputTokens) } +func TestOpenAIStreamingPassthroughBareErrorTerminatesBeforeDone(t *testing.T) { + errorEvent := "event: error\n" + + `data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\n\n" + upstream := errorEvent + "data: [DONE]\n\n" + + result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.NotNil(t, result) + body := recorder.Body.String() + require.NotContains(t, body, `"type":"error"`) + require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`)) + require.Contains(t, body, `"status":"failed"`) + require.NotContains(t, body, "[DONE]") + require.Equal(t, []int{len(body)}, writer.flushBodyLengths) + require.Equal(t, 6, result.usage.InputTokens) + require.Zero(t, result.usage.OutputTokens) +} + +func TestOpenAIStreamingPassthroughBareErrorDrainsAuthoritativeFailedUsage(t *testing.T) { + errorEvent := "event: error\n" + + `data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"}}` + "\n\n" + failedEvent := "event: response.failed\n" + + `data: {"type":"response.failed","response":{"id":"resp_failed","usage":{"input_tokens":9,"output_tokens":2},"error":{"code":"content_policy","message":"request blocked by policy"}}}` + "\n\n" + upstream := errorEvent + failedEvent + "data: [DONE]\n\n" + + result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.Error(t, err) + require.NotNil(t, result) + body := recorder.Body.String() + require.NotContains(t, body, `"type":"error"`) + require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`)) + require.Contains(t, body, `"id":"resp_failed"`) + require.NotContains(t, body, "[DONE]") + require.Equal(t, []int{len(body)}, writer.flushBodyLengths) + require.Equal(t, 9, result.usage.InputTokens) + require.Equal(t, 2, result.usage.OutputTokens) +} + func TestOpenAIStreamingPassthroughFailedAfterOutputFlushesAtBoundaryAndKeepsUsage(t *testing.T) { firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n" failedEvent := "event: response.failed\n" + diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index e59b3b205a..9f9c7b4c6a 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -504,6 +504,71 @@ func TestOpenAIGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputToke require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) } +func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesPricingAt(t *testing.T) { + groupID := int64(16) + requestStart := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC) // 上海 10:00 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverWithTimeForTest(t, groupID, "gpt-5.1", &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}, + }) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_time_pricing_request_start", + Model: "gpt-5.1", + Usage: OpenAIUsage{InputTokens: 1000, OutputTokens: 500}, + }, + APIKey: &APIKey{ID: 1006, GroupID: i64p(groupID), Group: &Group{ + ID: groupID, RateMultiplier: 0.8, SubscriptionType: SubscriptionTypeSubscription, + }}, + User: &User{ID: 2006}, + Account: &Account{ID: 3006}, + PricingAt: requestStart, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + baseCost := 1000*3e-6 + 500*15e-6 + require.InDelta(t, baseCost*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, baseCost*2*0.8, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesExplicitPricingAt(t *testing.T) { + groupID := int64(17) + pricingAt := time.Date(2024, time.January, 2, 0, 0, 0, 0, time.UTC) // 上海 08:00 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverWithTimeForTest(t, groupID, "gpt-5.1", &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}, + }) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_time_pricing_explicit", + Model: "gpt-5.1", + Usage: OpenAIUsage{InputTokens: 1000, OutputTokens: 500}, + }, + APIKey: &APIKey{ID: 1007, GroupID: i64p(groupID), Group: &Group{ + ID: groupID, RateMultiplier: 0.8, SubscriptionType: SubscriptionTypeSubscription, + }}, + User: &User{ID: 2007}, + Account: &Account{ID: 3007}, + PricingAt: pricingAt, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + baseCost := 1000*3e-6 + 500*15e-6 + require.InDelta(t, baseCost, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, baseCost*0.8, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) +} func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} @@ -1083,7 +1148,7 @@ func TestOpenAIGatewayServiceRecordUsage_GPT56SeparatesCacheWriteForBillingAndSt require.InDelta(t, usageRepo.lastLog.TotalCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) } -func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefault(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGroupAndAccountOff(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} @@ -1099,7 +1164,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefaul Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, true), + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, false), User: &User{ID: 2014}, Account: &Account{ID: 3014, Platform: PlatformOpenAI}, }) @@ -1154,7 +1219,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccoun require.True(t, usageRepo.lastLog.LongContextBillingApplied) } -func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *testing.T) { tokens := OpenAIUsage{InputTokens: 300000, OutputTokens: 2000} baseInput := 300000 * 2.5e-6 baseOutput := 2000 * 15e-6 @@ -1169,9 +1234,9 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow Account: &Account{ID: 3020, Platform: PlatformOpenAI}, }) require.NoError(t, err) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) - require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) - require.InDelta(t, baseOutput, usageRepo.lastLog.OutputCost, 1e-10) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) }) t.Run("group off account on", func(t *testing.T) { @@ -1187,8 +1252,9 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow }, }) require.NoError(t, err) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) - require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) }) t.Run("group on account on", func(t *testing.T) { @@ -1296,7 +1362,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, true), + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, false), User: &User{ID: 2016}, Account: &Account{ ID: 3016, @@ -2640,6 +2706,20 @@ func newOpenAITokenImageChannelPricingResolverForTest(t *testing.T, groupID int6 return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil)) } +func newOpenAITokenImageChannelPricingResolverWithTimeForTest( + t *testing.T, + groupID int64, + model string, + timePricing *ChannelTimePricing, +) *ModelPricingResolver { + t.Helper() + resolver := newOpenAITokenImageChannelPricingResolverForTest(t, groupID, model) + cached, ok := resolver.channelService.cache.Load().(*channelCache) + require.True(t, ok) + cached.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}].TimePricing = timePricing + return resolver +} + type openAIMediaPriceGroupRepoStub struct { GroupRepository group *Group @@ -2668,6 +2748,7 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun "gemini-image", 0.15, 1.0, + time.Time{}, nil, ) @@ -2707,6 +2788,7 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier( "gemini-image", 1.0, 1.0, + time.Time{}, nil, ) @@ -2739,6 +2821,7 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI "gemini-image", 1.0, 1.0, + time.Time{}, nil, ) @@ -2802,6 +2885,7 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis "gemini-image", 1.0, 1.0, + time.Time{}, nil, ) @@ -2810,3 +2894,64 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis require.InDelta(t, 0.44, cost.TotalCost, 1e-12) require.InDelta(t, 0.44, cost.ActualCost, 1e-12) } + +func TestOpenAIGatewayServiceRecordUsage_ServiceTierDowngradedByUpstreamResponse(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + serviceTier := "priority" + usage := OpenAIUsage{InputTokens: 100, OutputTokens: 50} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_service_tier_downgraded", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: usage, + Model: "gpt-5.4", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1017}, + User: &User{ID: 2017}, + Account: &Account{ID: 3017}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "default", *usageRepo.lastLog.ServiceTier, "usage log must record the tier actually billed") + + baseCost, calcErr := svc.billingService.CalculateCost("gpt-5.4", UsageTokens{InputTokens: 100, OutputTokens: 50}, 1.0) + require.NoError(t, calcErr) + require.InDelta(t, baseCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10, "a request served at default must not pay the priority price") +} + +func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + usage := OpenAIUsage{InputTokens: 100, OutputTokens: 50} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_service_tier_not_raised", + UpstreamResponseServiceTier: "priority", + Usage: usage, + Model: "gpt-5.4", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1018}, + User: &User{ID: 2018}, + Account: &Account{ID: 3018}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Nil(t, usageRepo.lastLog.ServiceTier) + + baseCost, calcErr := svc.billingService.CalculateCost("gpt-5.4", UsageTokens{InputTokens: 100, OutputTokens: 50}, 1.0) + require.NoError(t, calcErr) + require.InDelta(t, baseCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 04b3e7e050..dbe220773c 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -45,6 +45,97 @@ func buildOpenAIResponsesURL(base string) string { return buildOpenAIEndpointURL(base, "/v1/responses") } +// buildOpenAIResponsesURLForPlatform 组装 Responses 端点(平台感知)。 +// DeepSeek 官方 Responses 端点为 /responses(无 /v1 前缀,适配 Codex); +// 其余平台维持 /v1/responses。 +func buildOpenAIResponsesURLForPlatform(platform string, base string) string { + if platform == PlatformDeepseek { + return buildOpenAIEndpointURL(base, "/responses") + } + return buildOpenAIResponsesURL(base) +} + +func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { + if account == nil { + return false + } + if account.IsOpenAIOAuthLike() { + return true + } + if !account.IsOpenAIApiKey() { + return false + } + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) +} + +// Codex 0.149.0 needs a single advertised effort to directly select a visible +// non-reasoning model. Treat that catalog-only "none" value as omission for +// compatible upstreams, while preserving official OpenAI request semantics. +func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) { + if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) { + return body, nil + } + + out := body + for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + effort := gjson.GetBytes(out, path) + if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") { + continue + } + next, err := sjson.DeleteBytes(out, path) + if err != nil { + return body, fmt.Errorf("strip %s none placeholder: %w", path, err) + } + out = next + } + if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 { + next, err := sjson.DeleteBytes(out, "reasoning") + if err != nil { + return body, fmt.Errorf("strip empty reasoning object: %w", err) + } + out = next + } + return out, nil +} + +func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) { + if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) { + return + } + if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") { + delete(body, "reasoning_effort") + } + reasoning, ok := body["reasoning"].(map[string]any) + if !ok { + return + } + if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") { + delete(reasoning, "effort") + } + if len(reasoning) == 0 { + delete(body, "reasoning") + } +} + +// normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点: +// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 +// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 +func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte { + if account == nil || account.Platform != PlatformDeepseek || + (account.GetAPIProtocol() != APIProtocolResponses && !account.IsAdaptiveAPIProtocol()) { + return body + } + normalized, err := sjson.SetBytes(body, "store", false) + if err != nil { + return body + } + if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil { + normalized = stripped + } + return normalized +} + func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { if len(reqBody) == 0 { return false @@ -186,9 +277,7 @@ func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, ch return body, false, nil } var decoded map[string]any - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.UseNumber() - if err := decoder.Decode(&decoded); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &decoded); err != nil { return body, false, fmt.Errorf("decode cross-mode failover body: %w", err) } if !dropOpenAIEncryptedReasoningInputItems(decoded) { @@ -276,7 +365,9 @@ func isOpenAIEncryptedReasoningInputItem(item any) bool { return has } -func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { +// IsOpenAIResponsesCompactPath reports whether the request targets the legacy +// /responses/compact endpoint, including its forwardable subpaths. +func IsOpenAIResponsesCompactPath(c *gin.Context) bool { return isOpenAIResponsesCompactPath(c) } @@ -297,7 +388,6 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } - normalized := []byte(`{}`) // Keep the current Codex /compact schema while still dropping request-scoped // fields such as prompt_cache_key, store, and stream. @@ -322,6 +412,11 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { } normalized = next } + if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil { + return body, false, err + } else if removed { + normalized = next + } if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) { return body, false, nil @@ -329,8 +424,109 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { return normalized, true, nil } +func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) { + if responsesLite { + return body, false, nil + } + parallel := gjson.GetBytes(body, "parallel_tool_calls") + if !parallel.Exists() { + return body, false, nil + } + if openAIRequestBodyHasTools(body) { + return body, false, nil + } + normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls") + if err != nil { + return body, false, fmt.Errorf("normalize parallel_tool_calls without tools: %w", err) + } + return normalized, true, nil +} + +// openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。 +func openAIRequestBodyHasTools(body []byte) bool { + if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + for _, item := range gjson.GetBytes(body, "input").Array() { + if strings.TrimSpace(item.Get("type").String()) != "additional_tools" { + continue + } + if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + } + return false +} + +func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) { + if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False { + return body, false, nil + } + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body, false, nil + } + + var reqBody map[string]any + if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil { + return body, false, fmt.Errorf("normalize API-key store=false reasoning replay: %w", err) + } + items, ok := reqBody["input"].([]any) + if !ok { + return body, false, nil + } + filtered := make([]any, 0, len(items)) + changed := false + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + filtered = append(filtered, rawItem) + continue + } + typ := strings.TrimSpace(firstNonEmptyString(item["type"])) + id := strings.TrimSpace(firstNonEmptyString(item["id"])) + switch typ { + case "reasoning": + encryptedContent, hasEncryptedContent := item["encrypted_content"].(string) + if !hasEncryptedContent || strings.TrimSpace(encryptedContent) == "" { + changed = true + continue + } + if strings.HasPrefix(id, "rs_") { + delete(item, "id") + changed = true + } + if summary, ok := item["summary"]; !ok || summary == nil { + item["summary"] = []any{} + changed = true + } + case "item_reference": + if strings.HasPrefix(id, "rs_") { + changed = true + continue + } + } + if shouldStripOpenAIResponsesNonPairCallID(typ) { + if _, hasCallID := item["call_id"]; hasCallID { + delete(item, "call_id") + changed = true + } + } + filtered = append(filtered, item) + } + if !changed { + return body, false, nil + } + reqBody["input"] = filtered + normalized, err := marshalOpenAIUpstreamJSON(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize API-key store=false reasoning replay: %w", err) + } + return normalized, true, nil +} + func normalizeOpenAICodexCompactReasoningEffortForAccount(c *gin.Context, account *Account, body []byte) ([]byte, bool, error) { - if account == nil || !account.IsOpenAIOAuth() || !isOpenAIResponsesCompactPath(c) { + if account == nil || !account.IsOpenAIOAuthLike() || !isOpenAIResponsesCompactPath(c) { return body, false, nil } @@ -391,6 +587,12 @@ func IsForwardableOpenAIResponsesRequestPath(c *gin.Context) bool { return ok } +// IsOpenAIResponsesInputTokensRequestPath reports whether the request targets +// the native Responses input-token counting endpoint. +func IsOpenAIResponsesInputTokensRequestPath(c *gin.Context) bool { + return openAIResponsesRequestPathSuffix(c) == "/input_tokens" +} + // rawOpenAIResponsesRequestPathSuffix 仅做提取,不做任何安全判断。 func rawOpenAIResponsesRequestPathSuffix(c *gin.Context) string { if c == nil || c.Request == nil || c.Request.URL == nil { @@ -694,6 +896,243 @@ func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, p return view.Model, view.Stream, view.PromptCacheKey } +func normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + changed := false + if prompt, exists := reqBody["prompt"]; exists { + if input, hasInput := reqBody["input"]; !hasInput || input == nil { + if prompt != nil { + reqBody["input"] = prompt + } + } + delete(reqBody, "prompt") + changed = true + } + if _, exists := reqBody["commands"]; exists { + delete(reqBody, "commands") + changed = true + } + return changed +} + +func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + normalized := body + changed := false + prompt := gjson.GetBytes(normalized, "prompt") + if prompt.Exists() { + input := gjson.GetBytes(normalized, "input") + if prompt.Type != gjson.Null && (!input.Exists() || input.Type == gjson.Null) { + next, err := sjson.SetRawBytes(normalized, "input", []byte(prompt.Raw)) + if err != nil { + return body, false, fmt.Errorf("normalize oauth responses prompt: %w", err) + } + normalized = next + } + next, err := sjson.DeleteBytes(normalized, "prompt") + if err != nil { + return body, false, fmt.Errorf("normalize oauth responses delete prompt: %w", err) + } + normalized = next + changed = true + } + if gjson.GetBytes(normalized, "commands").Exists() { + next, err := sjson.DeleteBytes(normalized, "commands") + if err != nil { + return body, false, fmt.Errorf("normalize oauth responses delete commands: %w", err) + } + normalized = next + changed = true + } + return normalized, changed, nil +} + +func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + mode := gjson.GetBytes(body, "reasoning.mode") + if !mode.Exists() || mode.Type != gjson.String { + return body, false, nil + } + updated := body + effort := gjson.GetBytes(body, "reasoning.effort") + if (!effort.Exists() || effort.Type == gjson.Null || strings.TrimSpace(effort.String()) == "") && + strings.EqualFold(strings.TrimSpace(mode.String()), "pro") { + var err error + updated, err = sjson.SetBytes(updated, "reasoning.effort", "max") + if err != nil { + return body, false, fmt.Errorf("set reasoning effort for mode=pro: %w", err) + } + } + updated, err := sjson.DeleteBytes(updated, "reasoning.mode") + if err != nil { + return body, false, fmt.Errorf("delete unsupported reasoning.mode: %w", err) + } + if reasoning := gjson.GetBytes(updated, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 { + updated, err = sjson.DeleteBytes(updated, "reasoning") + if err != nil { + return body, false, fmt.Errorf("delete empty reasoning object: %w", err) + } + } + return updated, true, nil +} + +func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + textFormat := strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()) + responseFormat := strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String()) + if textFormat != "json_schema" && responseFormat != "json_schema" { + return body, false, nil + } + var reqBody map[string]any + if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil { + return body, false, fmt.Errorf("normalize responses schema body: %w", err) + } + if !normalizeOpenAIResponseFormatSchemas(reqBody) { + return body, false, nil + } + normalized, err := json.Marshal(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize normalized responses schema body: %w", err) + } + return normalized, true, nil +} + +func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) { + if account == nil || !account.IsOpenAI() { + return body, false, nil + } + normalized := body + changed := false + if account.IsOpenAIOAuthLike() { + var err error + normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body) + if err != nil { + return body, false, err + } + } + if account.IsOpenAIApiKey() { + if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil { + return body, false, err + } else if normalizedParallel { + normalized = next + changed = true + } + if next, normalizedReasoning, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(normalized, false); err != nil { + return body, false, err + } else if normalizedReasoning { + normalized = next + changed = true + } + } + if sanitized, idsChanged, err := sanitizeOpenAIResponsesInputItemIDs(normalized); err != nil { + return body, false, fmt.Errorf("sanitize websocket Responses input item IDs: %w", err) + } else if idsChanged { + normalized = sanitized + changed = true + } + if account != nil && account.IsOpenAI() && account.IsOAuth() { + if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized); err != nil { + return body, false, err + } else if reasoningChanged { + normalized = reasoningBody + changed = true + } + } + if account != nil && account.IsOpenAIOAuthLike() { + oauthBody, oauthChanged, err := normalizeOpenAIOAuthResponsesCompatibilityBody(normalized) + if err != nil { + return body, false, err + } + normalized = oauthBody + changed = changed || oauthChanged + for _, field := range openAIChatGPTInternalUnsupportedFields { + if !gjson.GetBytes(normalized, field).Exists() { + continue + } + next, deleteErr := sjson.DeleteBytes(normalized, field) + if deleteErr != nil { + return body, false, fmt.Errorf("normalize websocket body delete %s: %w", field, deleteErr) + } + normalized = next + changed = true + } + } + needsOrphanCleanup := account != nil && account.IsOpenAIOAuthLike() && + gjson.GetBytes(normalized, "input").IsArray() + if needsOrphanCleanup || openAIResponsesInputMayNeedTruncation(normalized) { + var reqBody map[string]any + if err := decodeOpenAIJSONUseNumber(normalized, &reqBody); err != nil { + return body, false, fmt.Errorf("normalize websocket Responses body: %w", err) + } + mapChanged := false + if needsOrphanCleanup { + if input, ok := reqBody["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs( + reqBody, + input, + strings.TrimSpace(firstNonEmptyString(reqBody["previous_response_id"])) != "", + ) { + mapChanged = true + } + } + if truncateOpenAIResponsesInputText(reqBody) { + mapChanged = true + } + if mapChanged { + next, err := marshalOpenAIUpstreamJSON(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize normalized websocket Responses body: %w", err) + } + normalized = next + changed = true + } + } + if schemaBody, schemaChanged, err := normalizeOpenAIResponseFormatSchemasBody(normalized); err != nil { + return body, false, err + } else if schemaChanged { + normalized = schemaBody + changed = true + } + if openAIRequestBodyImageGenerationToolNeedsNormalization(normalized) { + var reqBody map[string]any + if err := json.Unmarshal(normalized, &reqBody); err != nil { + return body, false, fmt.Errorf("normalize websocket image tool body: %w", err) + } + if normalizeOpenAIResponsesImageGenerationTools(reqBody) { + next, err := json.Marshal(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize normalized websocket image tool body: %w", err) + } + normalized = next + changed = true + } + } + if account != nil { + if schemaBody, schemaChanged, err := sanitizeOpenAIResponsesToolSchemasForPlatform(normalized, account.Platform); err != nil { + return body, false, fmt.Errorf("normalize websocket tool schemas: %w", err) + } else if schemaChanged { + normalized = schemaBody + changed = true + } + } + // Keep this last: earlier compatibility passes may filter or rebuild input. + // Remote compaction v2 requires one trigger as the final input item. + if triggerBody, triggerChanged, err := NormalizeCompactionTriggerInputOrder(normalized); err != nil { + return body, false, fmt.Errorf("normalize websocket compaction trigger order: %w", err) + } else if triggerChanged { + normalized = triggerBody + changed = true + } + return normalized, changed, nil +} + // normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为: // 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数 // 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false @@ -702,8 +1141,16 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo return body, false, nil } - normalized := body - changed := false + normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body) + if err != nil { + return body, false, err + } + if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized); reasoningErr != nil { + return body, false, reasoningErr + } else if reasoningChanged { + normalized = reasoningBody + changed = true + } for _, field := range openAIChatGPTInternalUnsupportedFields { if value := gjson.GetBytes(normalized, field); !value.Exists() { @@ -716,6 +1163,12 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo normalized = next changed = true } + if schemaBody, schemaChanged, schemaErr := normalizeOpenAIResponseFormatSchemasBody(normalized); schemaErr != nil { + return body, false, schemaErr + } else if schemaChanged { + normalized = schemaBody + changed = true + } if inputResult := gjson.GetBytes(normalized, "input"); inputResult.Exists() { switch { @@ -868,6 +1321,57 @@ func normalizeOpenAIServiceTier(raw string) *string { } } +// ErrInvalidOpenAIServiceTier indicates a request carried a service_tier value +// that is not a known OpenAI tier. HTTP handlers translate it into a 400 +// invalid_request_error so malformed values are rejected up front instead of +// being silently stripped (which would mask the user's intent to use fast +// mode). +type ErrInvalidOpenAIServiceTier struct { + Value string +} + +func (e *ErrInvalidOpenAIServiceTier) Error() string { + return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale", e.Value) +} + +const invalidOpenAIServiceTierValueMaxLen = 64 + +func boundInvalidOpenAIServiceTierValue(raw string) string { + if len(raw) <= invalidOpenAIServiceTierValueMaxLen { + return raw + } + return raw[:invalidOpenAIServiceTierValueMaxLen] + "..." +} + +// ValidateOpenAIServiceTierField validates the service_tier field of a raw +// OpenAI-compatible request body (/v1/responses and /v1/chat/completions). +// +// - absent / null → valid, returns "" (field omitted keeps current behavior) +// - "fast" → normalized to "priority" (the two are equivalent; the canonical +// value is what reaches the OpenAI upstream) +// - "priority" / "flex" / "auto" / "default" / "scale" → valid, returned as-is +// - an explicitly present non-string value, an empty string, or any other +// unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400), +// matching OpenAI's enum validation semantics +func ValidateOpenAIServiceTierField(body []byte) (string, error) { + tierResult := gjson.GetBytes(body, "service_tier") + if !tierResult.Exists() || tierResult.Type == gjson.Null { + return "", nil + } + if tierResult.Type != gjson.String { + return "", &ErrInvalidOpenAIServiceTier{Value: ""} + } + raw := strings.TrimSpace(tierResult.String()) + if raw == "" { + return "", &ErrInvalidOpenAIServiceTier{Value: raw} + } + norm := normalizedOpenAIServiceTierValue(raw) + if norm == "" { + return "", &ErrInvalidOpenAIServiceTier{Value: boundInvalidOpenAIServiceTierValue(raw)} + } + return norm, nil +} + // OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast // policy (action=block). Mirrors BetaBlockedError on the Claude side. type OpenAIFastBlockedError struct { @@ -1426,7 +1930,7 @@ func isEmptyBase64DataURI(raw string) bool { func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) { var reqBody map[string]any - if err := json.Unmarshal(body, &reqBody); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil { return nil, fmt.Errorf("parse request: %w", err) } return reqBody, nil @@ -1471,8 +1975,31 @@ func normalizeOpenAIReasoningEffort(raw string) string { } func normalizeOpenAIReasoningEffortForModel(raw, model string) string { - if strings.EqualFold(strings.TrimSpace(raw), "max") && isOpenAIGPT56Model(model) { + if strings.EqualFold(strings.TrimSpace(raw), "max") && supportsOpenAIReasoningEffortMax(model) { return "max" } return normalizeOpenAIReasoningEffort(raw) } + +// supportsOpenAIReasoningEffortMax reports model families whose upstream scale +// has a distinct max level. Other models keep the legacy max -> xhigh behavior. +func supportsOpenAIReasoningEffortMax(model string) bool { + if isOpenAIGPT56Model(model) { + return true + } + + normalized := strings.ToLower(lastOpenAIModelSegment(model)) + normalized = strings.ReplaceAll(normalized, "_", "-") + switch { + case strings.HasPrefix(normalized, "deepseek-v4"): + return true + case strings.HasPrefix(normalized, "glm-"): + return true + case strings.HasPrefix(normalized, "kimi-"), strings.HasPrefix(normalized, "moonshot-"): + return true + case normalized == "k3" || strings.HasPrefix(normalized, "k3-"): + return true + default: + return false + } +} diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index dc3f2d719d..8b5fbc8288 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -199,3 +199,157 @@ func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testi _, hasInput := reqBody["input"] assert.False(t, hasInput, "bare reasoning skeleton should be dropped, emptying input") } + +func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplay(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","store":false,"input":[` + + `{"type":"reasoning","id":"rs_encrypted","call_id":"remove","encrypted_content":"cipher","summary":null,"opaque":9007199254740993},` + + `{"type":"reasoning","id":"rs_server_only","summary":[{"type":"summary_text","text":"drop"}]},` + + `{"type":"item_reference","id":"rs_server_only"},` + + `{"type":"item_reference","id":"msg_keep"},` + + `{"type":"message","id":"msg_keep","role":"user","content":"continue"}` + + `]}`) + + normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, int64(3), gjson.GetBytes(normalized, "input.#").Int()) + require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.0.type").String()) + require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists()) + require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists()) + require.Equal(t, "cipher", gjson.GetBytes(normalized, "input.0.encrypted_content").String()) + require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray()) + require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.0.opaque").Raw) + require.Equal(t, "msg_keep", gjson.GetBytes(normalized, "input.1.id").String()) + require.Equal(t, "message", gjson.GetBytes(normalized, "input.2.type").String()) +} + +func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRequiresExplicitStoreFalse(t *testing.T) { + for _, body := range []string{ + `{"input":[{"type":"reasoning","id":"rs_keep"}]}`, + `{"store":true,"input":[{"type":"reasoning","id":"rs_keep"}]}`, + } { + normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay([]byte(body), false) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, body, string(normalized)) + } +} + +func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayKnownCompactMode(t *testing.T) { + body := []byte(`{"input":[{"type":"reasoning","id":"rs_drop","summary":[]},{"type":"message","content":"continue"}]}`) + + normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, true) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int()) + require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String()) +} + +func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedContent(t *testing.T) { + for _, encrypted := range []string{"null", `""`, `" "`, "123"} { + body := []byte(`{"store":false,"input":[{"type":"reasoning","id":"rs_drop","encrypted_content":` + encrypted + `},{"type":"message","content":"continue"}]}`) + normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int()) + require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String()) + } +} + +func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { + withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools, false) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(withTools), string(normalized)) + + withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools, false) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) +} + +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { + tests := []struct { + name string + account *Account + body string + wantNested bool + wantFlat bool + wantSummary bool + wantReasoning bool + }{ + { + name: "custom compatible endpoint strips none placeholders", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}}, + body: `{"reasoning":{"effort":"none"},"reasoning_effort":"NONE"}`, + wantReasoning: false, + }, + { + name: "third-party platform keeps other reasoning members", + account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + body: `{"reasoning":{"effort":" none ","summary":"auto"}}`, + wantSummary: true, + wantReasoning: true, + }, + { + name: "non-none effort is unchanged", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}}, + body: `{"reasoning":{"effort":"high"},"reasoning_effort":"low"}`, + wantNested: true, + wantFlat: true, + wantReasoning: true, + }, + { + name: "official OpenAI API key preserves none", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + body: `{"reasoning":{"effort":"none"},"reasoning_effort":"none"}`, + wantNested: true, + wantFlat: true, + wantReasoning: true, + }, + { + name: "OpenAI OAuth preserves none", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + body: `{"reasoning":{"effort":"none"}}`, + wantNested: true, + wantReasoning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(tt.account, []byte(tt.body)) + require.NoError(t, err) + require.Equal(t, tt.wantNested, gjson.GetBytes(got, "reasoning.effort").Exists()) + require.Equal(t, tt.wantFlat, gjson.GetBytes(got, "reasoning_effort").Exists()) + require.Equal(t, tt.wantSummary, gjson.GetBytes(got, "reasoning.summary").Exists()) + require.Equal(t, tt.wantReasoning, gjson.GetBytes(got, "reasoning").Exists()) + }) + } +} + +// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 +func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { + liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody, false) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) + + // 非 Lite 请求的空 additional_tools 不构成有效工具声明,字段仍需删除。 + emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody, false) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) + + // Lite 请求即使没有工具,也必须保留已经固定的 false。 + toolLessLiteBody := []byte(`{"input":"hi","parallel_tool_calls":false}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(toolLessLiteBody, true) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) +} diff --git a/backend/internal/service/openai_gateway_response_failed_passthrough_test.go b/backend/internal/service/openai_gateway_response_failed_passthrough_test.go index f9439c0551..bf62106aac 100644 --- a/backend/internal/service/openai_gateway_response_failed_passthrough_test.go +++ b/backend/internal/service/openai_gateway_response_failed_passthrough_test.go @@ -5,13 +5,16 @@ package service import ( "bytes" "context" + "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" + "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/model" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -84,6 +87,101 @@ func TestForwardAsChatCompletions_ResponseFailed_PassthroughRule(t *testing.T) { require.Contains(t, errMsg, "context window") } +func TestResponsesStreamAccessStateFailoverPrecedesPassthroughRule(t *testing.T) { + gin.SetMode(gin.TestMode) + stream := "event: response.failed\n" + + `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"account_disabled","message":"Your account is disabled"}}}` + "\n\n" + tests := []struct { + name string + run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error + }{ + { + name: "native", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5") + return err + }, + }, + { + name: "passthrough", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5") + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + bindPassthroughRule(c, PlatformOpenAI, []string{"account is disabled"}, http.StatusTeapot) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(stream)), + } + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + err := tt.run(svc, c, resp, &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth}) + + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.IsCredentialFailure()) + require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason) + require.False(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode) + require.False(t, c.Writer.Written(), "passthrough rule must not commit a response before account failover") + }) + } +} + +func TestResponsesStreamCyberPolicyPrecedesPassthroughRule(t *testing.T) { + gin.SetMode(gin.TestMode) + stream := "event: error\n" + + `data: {"type":"error","error":{"code":"cyber_policy","message":"blocked by cyber policy"}}` + "\n\n" + tests := []struct { + name string + run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error + }{ + { + name: "native", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5") + return err + }, + }, + { + name: "passthrough", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5") + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + bindPassthroughRule(c, PlatformOpenAI, []string{"cyber policy"}, http.StatusTeapot) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(stream)), + } + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + err := tt.run(svc, c, resp, &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth}) + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.NotNil(t, GetOpsCyberPolicy(c)) + require.NotEqual(t, http.StatusTeapot, rec.Code) + require.Contains(t, rec.Body.String(), "cyber_policy") + }) + } +} + func TestForwardAsAnthropic_ResponseFailed_PassthroughRule(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_response_flush_test.go b/backend/internal/service/openai_gateway_response_flush_test.go index ebfa0f22f1..fe6e3ea6b2 100644 --- a/backend/internal/service/openai_gateway_response_flush_test.go +++ b/backend/internal/service/openai_gateway_response_flush_test.go @@ -387,20 +387,41 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T) require.Contains(t, flushes[1], "response.failed") }) - t.Run("retryable error event buffered until terminal", func(t *testing.T) { - // 可重试类 error 帧不算客户端输出:保持在 attempt 缓冲中不单独 flush, - // 为随后可能到达的 response.failed 保留 pre-output failover 能力, - // 与终止帧一起出站。 + t.Run("bare error synthesizes failed before done", func(t *testing.T) { body := "data: {\"type\":\"error\",\"error\":{\"message\":\"failed\"}}\n\n" + "data: [DONE]\n\n" recorder := newOpenAIResponseFlushRecorder() result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}) - require.NoError(t, err) + require.Error(t, err) require.NotNil(t, result) gotBody, flushes := recorder.snapshot() - require.Equal(t, body, gotBody) + require.NotContains(t, gotBody, `"type":"error"`) + require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`)) + require.Contains(t, gotBody, `"status":"failed"`) + require.NotContains(t, gotBody, "[DONE]") + require.Len(t, flushes, 1) + }) + + t.Run("bare error forwards following failed and drains terminal usage", func(t *testing.T) { + errorEvent := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n" + body := errorEvent + + "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"usage\":{\"input_tokens\":9,\"output_tokens\":2},\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}}\n\n" + + "data: [DONE]\n\n" + recorder := newOpenAIResponseFlushRecorder() + + result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}) + + require.Error(t, err) + require.NotNil(t, result) + require.Equal(t, 9, result.usage.InputTokens) + require.Equal(t, 2, result.usage.OutputTokens) + gotBody, flushes := recorder.snapshot() + require.NotContains(t, gotBody, `"type":"error"`) + require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`)) + require.Contains(t, gotBody, `"id":"resp_failed"`) + require.NotContains(t, gotBody, "[DONE]") require.Len(t, flushes, 1) }) @@ -411,14 +432,112 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T) result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}) - require.NoError(t, err) + require.Error(t, err) require.NotNil(t, result) gotBody, flushes := recorder.snapshot() - require.Equal(t, body, gotBody) - require.Len(t, flushes, 2) + require.NotContains(t, gotBody, `"type":"error"`) + require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`)) + require.Len(t, flushes, 1) }) } +func TestOpenAIResponseFlush_BareErrorFollowedByCompletedUsesCompletedTerminal(t *testing.T) { + body := "data: {\"type\":\"error\",\"error\":{\"code\":\"transient\",\"message\":\"retrying\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_recovered\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}}\n\n" + recorder := newOpenAIResponseFlushRecorder() + + result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 7, result.usage.InputTokens) + require.Equal(t, 3, result.usage.OutputTokens) + gotBody, _ := recorder.snapshot() + require.NotContains(t, gotBody, `"type":"error"`) + require.NotContains(t, gotBody, `"type":"response.failed"`) + require.Contains(t, gotBody, `"type":"response.completed"`) +} + +func TestOpenAIResponseFlush_CompatibleAPIKeyDoesNotUseCodexBareErrorSynthesis(t *testing.T) { + body := "data: {\"type\":\"error\",\"error\":{\"code\":\"provider_error\",\"message\":\"provider failed\"}}\n\n" + recorder := newOpenAIResponseFlushRecorder() + account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + result, err := runOpenAIResponseFlushTestWithAccount(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}, account) + + require.Error(t, err) + require.NotNil(t, result) + gotBody, _ := recorder.snapshot() + require.Contains(t, gotBody, `"type":"error"`) + require.NotContains(t, gotBody, `"type":"response.failed"`) +} + +func TestOpenAIResponseFlush_RecentBareErrorAllowsCompletedBeforeIdleTimeout(t *testing.T) { + reader, writer := io.Pipe() + defer func() { _ = writer.Close() }() + recorder := newOpenAIResponseFlushRecorder() + resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 1}) + + // Place the bare error shortly before the first ticker firing. It is fresh + // data, so the ticker must leave the stream open for an authoritative event. + time.Sleep(700 * time.Millisecond) + _, err := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"transient\",\"message\":\"retrying\"}}\n\n") + require.NoError(t, err) + time.Sleep(500 * time.Millisecond) + _, err = io.WriteString(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_late\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n\n") + require.NoError(t, err) + require.NoError(t, writer.Close()) + + require.NoError(t, <-errCh) + result := <-resultCh + require.NotNil(t, result) + require.Equal(t, 5, result.usage.InputTokens) + gotBody, _ := recorder.snapshot() + require.Contains(t, gotBody, `"type":"response.completed"`) + require.NotContains(t, gotBody, `"type":"response.failed"`) +} + +func TestOpenAIResponseFlush_BareErrorTimeoutSynthesizesFailed(t *testing.T) { + tests := []struct { + name string + cfg config.GatewayConfig + }{ + { + name: "stream interval timeout", + cfg: config.GatewayConfig{StreamDataIntervalTimeout: 1}, + }, + { + name: "first output timeout", + cfg: config.GatewayConfig{OpenAIFirstOutputTimeoutSeconds: 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, writer := io.Pipe() + defer func() { _ = writer.Close() }() + recorder := newOpenAIResponseFlushRecorder() + resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, tt.cfg) + + _, writeErr := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n") + require.NoError(t, writeErr) + + select { + case err := <-errCh: + require.Error(t, err) + require.Contains(t, err.Error(), "upstream response failed") + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for bare error terminal synthesis") + } + require.NotNil(t, <-resultCh) + body, flushes := recorder.snapshot() + require.NotContains(t, body, `"type":"error"`) + require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`)) + require.Len(t, flushes, 1) + }) + } +} + func TestOpenAIResponseFlush_ReusedTypeKeepsSSEBytesAndTerminalSemantics(t *testing.T) { tests := []struct { name string @@ -471,6 +590,10 @@ func TestOpenAIResponseFlush_ClientDisconnectStillDrainsUsage(t *testing.T) { } func runOpenAIResponseFlushTest(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig) (*openaiStreamingResult, error) { + return runOpenAIResponseFlushTestWithAccount(recorder, body, gatewayCfg, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}) +} + +func runOpenAIResponseFlushTestWithAccount(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig, account *Account) (*openaiStreamingResult, error) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) @@ -483,7 +606,7 @@ func runOpenAIResponseFlushTest(recorder *openAIResponseFlushRecorder, body io.R Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: body, } - return svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "gpt-5", "gpt-5") + return svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5") } func runOpenAIResponseFlushTestAsync(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig) (<-chan *openaiStreamingResult, <-chan error) { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index cab620b0d6..a60e37feb5 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "sort" "strconv" "strings" "sync/atomic" @@ -15,10 +16,13 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/tidwall/gjson" "github.com/tidwall/sjson" + "go.uber.org/zap" ) // openaiStreamingResult streaming response result @@ -54,8 +58,9 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffort) } guardFirstOutput := firstOutputTimeout > 0 + stageFirstOutput := account != nil && account.Platform == PlatformOpenAI var attemptResponseHeaders http.Header - if guardFirstOutput { + if stageFirstOutput { if s.responseHeaderFilter != nil { attemptResponseHeaders = responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter) } else if requestID := strings.TrimSpace(resp.Header.Get("x-request-id")); requestID != "" { @@ -64,6 +69,14 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } else if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) } + // x-codex-turn-state 不在通用响应头白名单内,按 Codex 协议显式回传: + // 客户端会在同回合的后续请求中回带(openai_codex_turn_state.go)。 + // OpenAI 首个语义输出前只暂存,溯源在 applyAttemptResponseHeaders 真正提交时记录。 + if stageFirstOutput { + stageOpenAICodexTurnState(&attemptResponseHeaders, resp.Header) + } else { + s.relayOpenAICodexTurnState(c, account, resp.Header) + } // Set SSE response headers c.Header("Content-Type", "text/event-stream") @@ -72,12 +85,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. c.Header("X-Accel-Buffering", "no") // Pass through other headers - if !guardFirstOutput && resp.Header.Get("x-request-id") != "" { + if !stageFirstOutput && resp.Header.Get("x-request-id") != "" { v := resp.Header.Get("x-request-id") c.Header("x-request-id", v) } applyAttemptResponseHeaders := func() { - if !guardFirstOutput || len(attemptResponseHeaders) == 0 || c.Writer.Written() { + if !stageFirstOutput || len(attemptResponseHeaders) == 0 || c.Writer.Written() { return } for key, values := range attemptResponseHeaders { @@ -85,6 +98,9 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. c.Writer.Header().Add(key, value) } } + // 暂存头此刻才真正写给客户端:turn-state 溯源在这里记录(见 + // noteStagedOpenAICodexTurnStateCommitted 的 failover 说明)。 + s.noteStagedOpenAICodexTurnStateCommitted(c, account, attemptResponseHeaders) // These headers describe this gateway's SSE stream and are stable across // account attempts. Keep them authoritative over upstream values. c.Header("Content-Type", "text/event-stream") @@ -106,7 +122,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. firstOutputProgressObserved := false bufferedWriter := bufio.NewWriterSize(w, 4*1024) var firstOutputStage *openAIFirstOutputStage - if guardFirstOutput { + if stageFirstOutput { firstOutputStage = newDefaultOpenAIFirstOutputStage() defer func() { if err := firstOutputStage.Close(); err != nil { @@ -115,19 +131,19 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. }() } writePendingString := func(value string) (int, error) { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { return firstOutputStage.WriteString(value) } return bufferedWriter.WriteString(value) } pendingBytes := func() int64 { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { return firstOutputStage.Buffered() } return int64(bufferedWriter.Buffered()) } flushBuffered := func() error { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { if err := firstOutputStage.CommitTo(w); err != nil { return err } @@ -144,11 +160,11 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. imageCounter := newOpenAIImageOutputCounter() responseID := "" var firstOutputScanGuard atomic.Bool - firstOutputScanGuard.Store(guardFirstOutput) + firstOutputScanGuard.Store(stageFirstOutput) scanner := bufio.NewScanner(resp.Body) scanBuf := getSSEScannerBuf64K() scanner.Buffer(scanBuf[:0], maxLineSize) - if guardFirstOutput { + if stageFirstOutput { scanner.Split(openAIFirstOutputDynamicScanLines(&firstOutputScanGuard)) } documentScanner := newOpenAISSEJSONDocumentScanner(scanner) @@ -229,17 +245,28 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage sawTerminalEvent := false sawFailedEvent := false + sawBareError := false + sawResponseFailed := false + terminalEventType := "" responsesSemanticOutputSeen := false + capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false + codexFailureTerminal := account != nil && account.IsOpenAIOAuthLike() upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) var streamEarlyErr error + terminalFailurePending := false + failureDelivered := false + suppressCurrentEvent := false + var bareErrorPayload []byte + bareErrorAccountSideEffectsPending := false + pendingSSEEventType := "" eventInProgress := false eventStartsClientOutput := false eventStartsVisibleOutput := false eventShouldFlush := false handlePendingWriteError := func(err error) { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { message := "OpenAI first-output staging failed" if errors.Is(err, errOpenAIFirstOutputStageLimit) { message = "OpenAI first-output staging limit exceeded" @@ -287,7 +314,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. eventShouldFlush = false } sendErrorEvent := func(reason string) { - if errorEventSent || clientDisconnected { + if errorEventSent || clientDisconnected || failureDelivered { return } errorEventSent = true @@ -310,6 +337,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. needModelReplace := originalModel != mappedModel streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() + streamDoneItems := newResponsesStreamOutputItems() streamImageOutputs := make([]json.RawMessage, 0, 1) streamSeenImages := make(map[string]struct{}) searchCounter := 0 @@ -339,10 +367,22 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. lastDownstreamWriteAt = time.Now() } finalizeStream := func() (*openaiStreamingResult, error) { - if guardFirstOutput && eventInProgress { + if stageFirstOutput && eventInProgress { // EOF dispatches the final SSE event even without a trailing blank line. completeGuardedEvent(true) } + if codexFailureTerminal && sawBareError && !sawResponseFailed && bareErrorAccountSideEffectsPending { + s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header) + bareErrorAccountSideEffectsPending = false + } + if codexFailureTerminal && sawBareError && !sawResponseFailed && !clientDisconnected { + applyAttemptResponseHeaders() + if _, err := writePendingString(buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil { + handlePendingWriteError(err) + } else { + failureDelivered = true + } + } if sawTerminalEvent && !sawFailedEvent { s.clearOpenAIProxyStreamDisconnect(account) } @@ -366,6 +406,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if sawFailedEvent { return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) } + logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected) return resultWithUsage(), nil } handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) { @@ -381,7 +422,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. failoverErr.SafeToFailoverAfterWrite = true return resultWithUsage(), failoverErr, true } - if errors.Is(scanErr, bufio.ErrTooLong) && guardFirstOutput && !firstOutputProgressObserved { + if errors.Is(scanErr, bufio.ErrTooLong) && stageFirstOutput && !firstOutputProgressObserved { logger.LegacyPrintf("service.openai_gateway", "SSE line too long before first output: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) failoverErr := s.newOpenAIStreamFailoverError( c, account, false, upstreamRequestID, nil, @@ -430,27 +471,69 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if streamEarlyErr != nil { return } + if eventType, ok := extractOpenAISSEEventLine(line); ok { + pendingSSEEventType = eventType + eventType = strings.TrimSpace(eventType) + suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed")) + } // Extract data from SSE line (supports both "data: " and "data:" formats) if data, ok := extractOpenAISSEDataLine(line); ok { dataBytes := []byte(data) - eventTypeRaw := gjson.GetBytes(dataBytes, "type").String() - eventType := strings.TrimSpace(eventTypeRaw) - observer.ObserveOpenAI(dataBytes, eventTypeRaw) + eventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType) + if codexFailureTerminal && sawBareError && !sawResponseFailed && + (eventType == "response.completed" || eventType == "response.done") { + // A later successful terminal is authoritative over a pending bare + // error. Keep its usage and terminal visible to the client. + sawBareError = false + sawFailedEvent = false + terminalFailurePending = false + suppressCurrentEvent = false + bareErrorPayload = nil + bareErrorAccountSideEffectsPending = false + failedMessage = "" + } + if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" { + suppressCurrentEvent = true + } + observer.ObserveOpenAI(dataBytes, eventType) // 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。 - if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) { + if openAIStreamEventIsTerminalWithType(data, eventType) { sawTerminalEvent = true + terminalEventType = eventType + if strings.TrimSpace(data) == "[DONE]" { + terminalEventType = "[DONE]" + } } if responseID == "" { responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) } forceFlushFailedEvent := false - if eventType == "response.failed" { + if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI && + (eventType == "error" || eventType == "response.failed") && + openAIStreamClientOutputStarted(c, clientOutputStarted) && + isOpenAIUpstreamCapacityShedEvent(dataBytes) { + logOpenAICapacityFailoverSuppressed(ctx, account, "native_sse", upstreamRequestID, eventType) + capacityFailoverSuppressedLogged = true + } + cyberHit := false + if eventType == "response.failed" || eventType == "error" { + if codexFailureTerminal && eventType == "error" { + sawBareError = true + bareErrorPayload = append(bareErrorPayload[:0], dataBytes...) + suppressCurrentEvent = true + } else if codexFailureTerminal && eventType == "response.failed" { + sawResponseFailed = true + } failedMessage = extractOpenAISSEErrorMessage(dataBytes) + if failedMessage == "" { + failedMessage = "Upstream response failed" + } // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 // 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费 // 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。 - s.parseSSEUsageBytes(dataBytes, usage) + s.parseSSEUsageBytesWithType(dataBytes, eventType, usage) if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit { + cyberHit = true MarkOpsCyberPolicy(c, CyberPolicyMark{ Code: code, Message: msg, @@ -460,31 +543,60 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. UpstreamOutTok: usage.OutputTokens, }) } - if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched { + outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted) + if !outputStarted && !cyberHit { + if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil { sawFailedEvent = true - // 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与 - // antigravity 先例),否则透传命中的 failed 在监控中不可见。 - s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage) - MarkResponseCommitted(c) - c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - c.JSON(status, gin.H{ - "error": gin.H{ - "type": errType, - "message": errMsg, - }, - }) - streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg) + streamEarlyErr = compactErr return } - if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { + } + if outputStarted && !cyberHit { + if codexFailureTerminal && eventType == "error" { + // OpenAI commonly follows a bare error with response.failed. + // Defer account health updates so the pair is applied once. + bareErrorAccountSideEffectsPending = true + } else { + s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header) + bareErrorAccountSideEffectsPending = false + } + } + if !outputStarted { + shouldFailover := false + if !cyberHit { + if eventType == "error" { + shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage) + } else { + shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) + } + } + if shouldFailover { sawFailedEvent = true streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage, resp.Header) return } + if !cyberHit && !sawBareError { + if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched { + sawFailedEvent = true + // 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与 + // antigravity 先例),否则透传命中的 failed 在监控中不可见。 + s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage) + MarkResponseCommitted(c) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg) + return + } + } } forceFlushFailedEvent = true sawFailedEvent = true + terminalFailurePending = !codexFailureTerminal || eventType == "response.failed" } if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized { dataBytes = normalizedData @@ -499,22 +611,23 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. dataBytes = correctedData data = string(correctedData) line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + eventType = effectiveOpenAISSEEventType(dataBytes, eventType) } if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok { streamImageOutputs = append(streamImageOutputs, imageOutput) } + streamDoneItems.Observe(dataBytes) if responsesStreamEventMayContributeToOutput(eventType) { var streamEvent apicompat.ResponsesStreamEvent if err := json.Unmarshal(dataBytes, &streamEvent); err == nil { streamOutputAccumulator.ProcessEvent(&streamEvent) } } - if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamImageOutputs); normalized { + if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamDoneItems, streamImageOutputs); normalized { dataBytes = normalizedData data = string(normalizedData) line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + eventType = effectiveOpenAISSEEventType(dataBytes, eventType) } restoredData, restoreErr := restoreGrokResponsesClientToolPayload(c, dataBytes) if restoreErr != nil { @@ -526,11 +639,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. streamEarlyErr = fmt.Errorf("restore OpenAI namespace response: %w", restoreErr) return } + restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, eventType) if !bytes.Equal(restoredData, dataBytes) { dataBytes = restoredData data = string(restoredData) line = "data: " + data - eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + eventType = effectiveOpenAISSEEventType(dataBytes, eventType) } if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient( dataBytes, @@ -548,9 +662,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) startsVisibleOutput := openAIStreamDataStartsVisibleOutput(data, eventType) - if guardFirstOutput { + if stageFirstOutput { eventStartsClientOutput = eventStartsClientOutput || startsClientOutput eventStartsVisibleOutput = eventStartsVisibleOutput || startsVisibleOutput + if startsClientOutput { + firstOutputScanGuard.Store(false) + } } if startsClientOutput && !openAIStreamEventTypeIsTerminal(eventType) { responsesSemanticOutputSeen = true @@ -569,7 +686,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } // 写入客户端(客户端断开后继续 drain 上游) - if !clientDisconnected { + if !clientDisconnected && !failureDelivered && !suppressCurrentEvent { shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput) if firstTokenMs == nil && startsVisibleOutput { // 保证首个 token 事件尽快出站,避免影响 TTFT。 @@ -591,12 +708,30 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. firstTokenMs = &ms stopFirstOutputTimer() } - s.parseSSEUsageBytes(dataBytes, usage) + s.parseSSEUsageBytesWithType(dataBytes, eventType, usage) return } // A blank line dispatches a guarded event from the attempt-local stage. - if guardFirstOutput && line == "" { + if stageFirstOutput && line == "" { + pendingSSEEventType = "" + if suppressCurrentEvent { + suppressCurrentEvent = false + terminalFailurePending = false + eventInProgress = false + eventStartsClientOutput = false + eventStartsVisibleOutput = false + eventShouldFlush = false + return + } + if failureDelivered { + terminalFailurePending = false + eventInProgress = false + eventStartsClientOutput = false + eventStartsVisibleOutput = false + eventShouldFlush = false + return + } if !clientDisconnected { if _, err := writePendingString("\n"); err != nil { handlePendingWriteError(err) @@ -605,16 +740,31 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if streamEarlyErr == nil { completeGuardedEvent(queueDrained) } + if terminalFailurePending && streamEarlyErr == nil { + terminalFailurePending = false + failureDelivered = true + } return } // Non-guarded streams retain upstream's event-boundary flushing: a keepalive // or queue-drain flush must never split an open SSE event. shouldFlush := false if line == "" { + pendingSSEEventType = "" + if suppressCurrentEvent { + suppressCurrentEvent = false + terminalFailurePending = false + eventInProgress = false + eventShouldFlush = false + return + } shouldFlush = eventShouldFlush || (queueDrained && clientOutputStarted) eventShouldFlush = false + if failureDelivered { + terminalFailurePending = false + } } - if !clientDisconnected { + if !clientDisconnected && !failureDelivered && !suppressCurrentEvent { if _, err := writePendingString(line); err != nil { handlePendingWriteError(err) } else if _, err := writePendingString("\n"); err != nil { @@ -631,6 +781,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } } } + if line == "" && terminalFailurePending && streamEarlyErr == nil { + terminalFailurePending = false + failureDelivered = true + } } } @@ -661,7 +815,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. events := make(chan scanEvent, openAIFirstOutputEventQueueSize(guardFirstOutput)) done := make(chan struct{}) sendEvent := func(ev scanEvent) bool { - if guardFirstOutput { + if firstOutputScanGuard.Load() { ev.processed = make(chan struct{}) } select { @@ -705,7 +859,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. select { case ev, ok := <-events: if !ok { - if guardFirstOutput && eventInProgress { + if stageFirstOutput && eventInProgress { // EOF dispatches the final SSE event even without a trailing blank // line. Do not synthesize extra bytes on the downstream wire. completeGuardedEvent(true) @@ -723,10 +877,17 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } case <-intervalCh: + if failureDelivered { + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) + } lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) if time.Since(lastRead) < streamInterval { continue } + if codexFailureTerminal && sawBareError && !sawResponseFailed { + _ = resp.Body.Close() + return finalizeStream() + } if clientDisconnected { return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") } @@ -753,6 +914,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. stopFirstOutputTimer() continue } + if codexFailureTerminal && sawBareError && !sawResponseFailed && len(events) == 0 { + _ = resp.Body.Close() + return finalizeStream() + } _ = resp.Body.Close() for ev := range events { markEventProcessed(ev) @@ -763,7 +928,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. ) case <-keepaliveCh: - if clientDisconnected { + if clientDisconnected || failureDelivered { continue } if eventInProgress { @@ -772,10 +937,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if time.Since(lastDownstreamWriteAt) < keepaliveInterval { continue } - if guardFirstOutput { + if stageFirstOutput { // Bypass attempt-local buffered frames. The stable SSE headers may be // committed here, but account headers remain private until semantic output. - if _, err := w.Write([]byte(":\n\n")); err != nil { + n, err := w.Write([]byte(":\n\n")) + recordOpenAIStreamKeepaliveBytes(c, n) + if err != nil { clientDisconnected = true logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") continue @@ -876,7 +1043,7 @@ func openAICompatPayloadWithEventType(payload, eventType string) string { if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" { return payload } - if gjson.Get(payload, "type").Exists() { + if strings.TrimSpace(gjson.Get(payload, "type").String()) != "" { return payload } patched, err := sjson.Set(payload, "type", eventType) @@ -886,6 +1053,13 @@ func openAICompatPayloadWithEventType(payload, eventType string) string { return patched } +func effectiveOpenAISSEEventType(payload []byte, eventType string) string { + if payloadType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()); payloadType != "" { + return payloadType + } + return strings.TrimSpace(eventType) +} + func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { data, ok := extractOpenAISSEDataLine(line) if !ok { @@ -1015,22 +1189,126 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) { } func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) { - if usage == nil || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { - return - } - // 选择性解析:仅在数据中包含终止事件标识时才进入字段提取。 - if len(data) < 72 { - return - } - eventType := gjson.GetBytes(data, "type").String() - if eventType != "response.completed" && eventType != "response.done" && eventType != "response.failed" && - eventType != "response.incomplete" && eventType != "response.cancelled" && eventType != "response.canceled" { - return - } + s.parseSSEUsageBytesWithType(data, "", usage) +} - if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data); ok { - *usage = parsedUsage +func (s *OpenAIGatewayService) parseSSEUsageBytesWithType(data []byte, eventType string, usage *OpenAIUsage) { + if usage == nil || len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { + return } + // Usage is absent from nearly every delta event. Avoid full JSON validation + // and four gjson path scans on that hot path while retaining progressive + // usage from compatible upstreams on any event type. + if !bytes.Contains(data, []byte(`"usage"`)) { + return + } + parsedUsage, ok := extractOpenAIUsageFromJSONBytes(data) + if !ok { + return + } + if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(data, eventType)) { + if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) { + return + } + *usage = parsedUsage + return + } + mergeOpenAIUsageNonZero(usage, parsedUsage) +} + +// Compatible Responses upstreams may report usage before the terminal event. +// Retain those non-zero fields as a fallback. A terminal usage with any billed +// tokens is authoritative as a whole; an all-zero terminal does not erase a +// non-zero progressive observation from the same turn. +func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) { + if dst == nil { + return + } + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.ImageInputTokens > 0 { + dst.ImageInputTokens = src.ImageInputTokens + } + if src.OutputTokens > 0 { + dst.OutputTokens = src.OutputTokens + } + if src.CacheCreationInputTokens > 0 { + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } + if src.CacheReadInputTokens > 0 { + dst.CacheReadInputTokens = src.CacheReadInputTokens + } + if src.ImageOutputTokens > 0 { + dst.ImageOutputTokens = src.ImageOutputTokens + } +} + +func openAIUsageHasTokens(usage *OpenAIUsage) bool { + return usage != nil && (usage.InputTokens > 0 || usage.ImageInputTokens > 0 || + usage.OutputTokens > 0 || usage.CacheCreationInputTokens > 0 || + usage.CacheReadInputTokens > 0 || usage.ImageOutputTokens > 0) +} + +const openAIMissingUsageLogInterval = time.Minute + +type openAIMissingUsageLogSampler struct { + total atomic.Uint64 + suppressed atomic.Uint64 + lastLog atomic.Int64 +} + +var openAIMissingUsageSampler openAIMissingUsageLogSampler + +func (s *openAIMissingUsageLogSampler) sample(now time.Time) (logNow bool, total uint64, suppressed uint64) { + total = s.total.Add(1) + nowNanos := now.UnixNano() + for { + last := s.lastLog.Load() + if last != 0 && nowNanos-last < int64(openAIMissingUsageLogInterval) { + s.suppressed.Add(1) + return false, total, 0 + } + if s.lastLog.CompareAndSwap(last, nowNanos) { + return true, total, s.suppressed.Swap(0) + } + } +} + +func logOpenAISuccessMissingUsage(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, usage *OpenAIUsage, terminalEvent string, clientDisconnected bool) { + if resp == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || openAIUsageHasTokens(usage) { + return + } + terminalEvent = strings.TrimSpace(terminalEvent) + if terminalEvent != "response.completed" && terminalEvent != "response.done" && terminalEvent != "[DONE]" && terminalEvent != "json" { + return + } + logNow, total, suppressed := openAIMissingUsageSampler.sample(time.Now()) + if !logNow { + return + } + accountID := int64(0) + accountType := "" + if account != nil { + accountID = account.ID + accountType = string(account.Type) + } + inboundEndpoint := "" + if c != nil && c.Request != nil && c.Request.URL != nil { + inboundEndpoint = c.Request.URL.Path + } + logger.FromContext(ctx).With( + zap.String("component", "service.openai_gateway"), + zap.Int64("account_id", accountID), + zap.String("account_type", accountType), + zap.String("inbound_endpoint", inboundEndpoint), + zap.String("upstream_request_id", strings.TrimSpace(resp.Header.Get("x-request-id"))), + zap.Int("upstream_status_code", resp.StatusCode), + zap.String("terminal_event", terminalEvent), + zap.Bool("client_disconnected", clientDisconnected), + zap.Uint64("missing_usage_total", total), + zap.Uint64("suppressed_since_last", suppressed), + ).Warn("openai_usage.success_missing_usage") } func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { @@ -1111,6 +1389,57 @@ func extractOpenAIResponseIDFromJSONBytes(body []byte) string { return strings.TrimSpace(gjson.GetBytes(body, "response.id").String()) } +const openAIHTTPResponseOwnerContextKey = "openai_http_response_owner" + +type openAIHTTPResponseOwner struct { + userID int64 + apiKeyID int64 +} + +// SetOpenAIHTTPResponseOwner marks the authenticated downstream owner whose +// successful Responses IDs may be used for later HTTP continuations. +func SetOpenAIHTTPResponseOwner(c *gin.Context, userID, apiKeyID int64) { + if c == nil || userID <= 0 || apiKeyID <= 0 { + return + } + c.Set(openAIHTTPResponseOwnerContextKey, openAIHTTPResponseOwner{userID: userID, apiKeyID: apiKeyID}) +} + +// ValidateOpenAIHTTPResponseOwner authorizes a continuation by downstream +// tenant. API key identity is retained in the binding, while keys owned by the +// same user remain interoperable. +func (s *OpenAIGatewayService) ValidateOpenAIHTTPResponseOwner( + ctx context.Context, + groupID int64, + responseID string, + userID, apiKeyID int64, +) (bool, error) { + if s == nil || strings.TrimSpace(responseID) == "" || userID <= 0 || apiKeyID <= 0 { + return false, nil + } + ownerUserID, ownerAPIKeyID, found, err := s.getOpenAIWSStateStore().GetHTTPResponseOwner(ctx, groupID, responseID) + if err != nil || !found { + return false, err + } + return ownerUserID == userID || (ownerUserID <= 0 && ownerAPIKeyID == apiKeyID), nil +} + +// BindOpenAIHTTPResponseOwner records an HTTP continuation owner independently +// from the upstream account selected for that response. +func (s *OpenAIGatewayService) BindOpenAIHTTPResponseOwner( + ctx context.Context, + groupID int64, + responseID string, + userID, apiKeyID int64, +) error { + if s == nil { + return nil + } + return s.getOpenAIWSStateStore().BindHTTPResponseOwner( + ctx, groupID, responseID, userID, apiKeyID, s.openAIWSResponseStickyTTL(), + ) +} + func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *gin.Context, account *Account, responseID string) { if s == nil || account == nil || account.ID <= 0 { return @@ -1126,6 +1455,21 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g groupID := getOpenAIGroupIDFromContext(c) ttl := s.openAIWSResponseStickyTTL() logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) + if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok { + if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 { + if err := s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil { + logger.L().Warn( + "openai.http_bind_response_owner_failed", + zap.Int64("group_id", groupID), + zap.Int64("account_id", account.ID), + zap.Int64("user_id", owner.userID), + zap.Int64("api_key_id", owner.apiKeyID), + zap.String("response_id", truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen)), + zap.Error(err), + ) + } + } + } } func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { @@ -1140,6 +1484,17 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { if outputTokens == 0 { outputTokens = value.Get("completion_tokens").Int() } + // xAI reports visible output separately from reasoning_tokens; OpenAI + // folds reasoning into completion/output. Use total_tokens to tell them apart. + reasoningTokens := max(int(firstPositiveGJSONInt( + value.Get("completion_tokens_details.reasoning_tokens"), + value.Get("output_tokens_details.reasoning_tokens"), + )), 0) + if reasoningTokens > 0 { + outputTokens = xai.IncludeIndependentReasoningTokens( + inputTokens, outputTokens, value.Get("total_tokens").Int(), int64(reasoningTokens), + ) + } cacheReadTokens := openAICacheReadTokensFromUsage(value) cacheCreationTokens := openAICacheCreationTokensFromUsage(value) imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int() @@ -1219,7 +1574,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r // Some OpenAI-compatible upstreams (including other sub2api instances) // may return SSE even when stream=false was requested. if isEventStreamResponse(resp.Header) { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel) } // bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires // "data:"/"event:" field names at the very start of a physical line. A @@ -1235,7 +1590,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r // positives on JSON responses that coincidentally contain "data:" or // "event:" in their text content. if account.Type == AccountTypeOAuth && bodyLooksLikeSSE { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel) } if account != nil && account.IsGrok() && isOpenAIResponsesCompactPath(c) { body, err = convertGrokResponseToOpenAICompact(body) @@ -1247,11 +1602,12 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body) if !usageOK { if bodyLooksLikeSSE { - return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel) } return nil, fmt.Errorf("parse response: invalid json response") } usage := &usageValue + logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false) // Replace model in response if needed if originalModel != mappedModel { @@ -1261,11 +1617,19 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r if err != nil { return nil, fmt.Errorf("restore Grok Responses client tool response: %w", err) } + body, err = restoreOpenAIResponsesClientToolPayload(c, body) + if err != nil { + return nil, fmt.Errorf("restore OpenAI Responses client tool response: %w", err) + } body, err = restoreOpenAIResponsesNamespacePayload(c, body) if err != nil { return nil, fmt.Errorf("restore OpenAI namespace response: %w", err) } + body = restoreCodexToolNamesFromContext(c, body) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + // Codex 协议要求 /responses/compact JSON 响应携带 x-codex-turn-state + // (codex-api/src/endpoint/compact.rs 从响应头捕获),显式回传。 + s.relayOpenAICodexTurnState(c, account, resp.Header) contentType := "application/json" if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { @@ -1309,11 +1673,22 @@ func bodyHasSSEFraming(body []byte) bool { return false } -func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { +func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { bodyText := string(body) + terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) + if terminalOK && (terminalType == "response.failed" || terminalType == "error") { + msg := extractOpenAISSEErrorMessage(terminalPayload) + if msg == "" { + msg = "Upstream compact response failed" + } + if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil { + return nil, compactErr + } + return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) + } finalResponse, ok := extractCodexFinalResponse(bodyText) - usage := &OpenAIUsage{} + usage := s.parseSSEUsageFromBody(bodyText) if ok { if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { *usage = parsedUsage @@ -1339,21 +1714,17 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte if restoreErr != nil { return nil, fmt.Errorf("restore Grok Responses client tool response: %w", restoreErr) } + restoredBody, restoreErr = restoreOpenAIResponsesClientToolPayload(c, restoredBody) + if restoreErr != nil { + return nil, fmt.Errorf("restore OpenAI Responses client tool response: %w", restoreErr) + } restoredBody, restoreErr = restoreOpenAIResponsesNamespacePayload(c, restoredBody) if restoreErr != nil { return nil, fmt.Errorf("restore OpenAI namespace response: %w", restoreErr) } + restoredBody = restoreCodexToolNamesFromContext(c, restoredBody) body = restoredBody } else { - terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) - if terminalOK && terminalType == "response.failed" { - msg := extractOpenAISSEErrorMessage(terminalPayload) - if msg == "" { - msg = "Upstream compact response failed" - } - return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) - } - usage = s.parseSSEUsageFromBody(bodyText) if originalModel != mappedModel { bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel) } @@ -1361,6 +1732,8 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte } responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false) + s.relayOpenAICodexTurnState(c, account, resp.Header) contentType := "application/json; charset=utf-8" if !ok { @@ -1386,13 +1759,9 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) { var terminalType string var terminalPayload []byte - forEachOpenAISSEDataPayload(body, func(data []byte) { - if terminalPayload != nil { - return - } - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) + forEachOpenAISSEFrame(body, func(eventType string, data []byte) { switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error": terminalType = eventType terminalPayload = append([]byte(nil), data...) } @@ -1415,6 +1784,54 @@ func extractOpenAISSEErrorMessage(payload []byte) string { return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload))) } +func buildOpenAIResponseFailedSSE(responseID, model string, source []byte, fallbackMessage string) string { + responseID = strings.TrimSpace(responseID) + if responseID == "" { + responseID = "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "") + } + errorType := strings.TrimSpace(gjson.GetBytes(source, "error.type").String()) + if errorType == "" { + errorType = strings.TrimSpace(gjson.GetBytes(source, "response.error.type").String()) + } + code := strings.TrimSpace(gjson.GetBytes(source, "error.code").String()) + if code == "" { + code = strings.TrimSpace(gjson.GetBytes(source, "response.error.code").String()) + } + if code == "" { + code = "upstream_error" + } + message := extractOpenAISSEErrorMessage(source) + if message == "" { + message = strings.TrimSpace(fallbackMessage) + } + if message == "" { + message = "Upstream response failed" + } + errorBody := gin.H{"code": code, "message": message} + if errorType != "" { + errorBody["type"] = errorType + } + response := gin.H{ + "id": responseID, + "object": "response", + "status": "failed", + "output": []any{}, + "error": errorBody, + } + if model = strings.TrimSpace(model); model != "" { + response["model"] = model + } + payload, err := marshalOpenAIUpstreamJSON(gin.H{ + "type": "response.failed", + "response": response, + }) + if err != nil { + // All values above are JSON primitives, so this is only a defensive fallback. + payload = []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`) + } + return "event: response.failed\ndata: " + string(payload) + "\n\n" +} + func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string, clientOutputStarted bool) ([]byte, bool) { eventType = strings.TrimSpace(eventType) isFailedEvent := eventType == "response.failed" @@ -1503,14 +1920,13 @@ func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.R func extractCodexFinalResponse(body string) ([]byte, bool) { var finalResponse []byte - forEachOpenAISSEDataPayload(body, func(data []byte) { + forEachOpenAISSEFrame(body, func(eventType string, data []byte) { if finalResponse != nil { return } if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed { data = normalized } - eventType := gjson.GetBytes(data, "type").String() if eventType == "response.done" || eventType == "response.completed" { if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { finalResponse = []byte(response.Raw) @@ -1576,7 +1992,75 @@ func normalizeCompletedImageGenerationStatus(data []byte) ([]byte, bool) { } } -func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { +// responsesStreamOutputItems remembers the raw item carried by each +// response.output_item.done event, keyed by output_index. +// +// reconstructResponseOutputFromSSE already prefers the raw done items over +// delta accumulation when it rebuilds a buffered response, because the +// accumulator models only "one reasoning, one message, N function calls" and +// therefore cannot preserve item identity, per-item status/phase, ordering, or +// item types it does not know about. The streaming path had no equivalent +// because it never sees the whole body at once; this collector gives it one. +type responsesStreamOutputItems struct { + items map[int]json.RawMessage +} + +func newResponsesStreamOutputItems() *responsesStreamOutputItems { + return &responsesStreamOutputItems{items: make(map[int]json.RawMessage)} +} + +// Observe records the item of a response.output_item.done event verbatim. The +// raw JSON is kept byte for byte so vendor extensions and future fields survive +// the rebuild. +func (r *responsesStreamOutputItems) Observe(data []byte) { + if r == nil || len(data) == 0 || !gjson.ValidBytes(data) { + return + } + if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.done" { + return + } + item := gjson.GetBytes(data, "item") + if !item.Exists() || !item.IsObject() { + return + } + index := int(gjson.GetBytes(data, "output_index").Int()) + r.items[index] = json.RawMessage(append([]byte(nil), item.Raw...)) +} + +func (r *responsesStreamOutputItems) HasItems() bool { + return r != nil && len(r.items) > 0 +} + +// Count reports how many distinct output items the stream reported as done. +func (r *responsesStreamOutputItems) Count() int { + if r == nil { + return 0 + } + return len(r.items) +} + +// BuildOutput returns the remembered items ordered by output_index. +func (r *responsesStreamOutputItems) BuildOutput() ([]byte, bool) { + if !r.HasItems() { + return nil, false + } + indexes := make([]int, 0, len(r.items)) + for index := range r.items { + indexes = append(indexes, index) + } + sort.Ints(indexes) + ordered := make([]json.RawMessage, 0, len(indexes)) + for _, index := range indexes { + ordered = append(ordered, r.items[index]) + } + encoded, err := json.Marshal(ordered) + if err != nil { + return nil, false + } + return encoded, true +} + +func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, doneItems *responsesStreamOutputItems, imageOutputs []json.RawMessage) ([]byte, bool) { eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) switch eventType { case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled": @@ -1585,15 +2069,28 @@ func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.Buffe } output := gjson.GetBytes(data, "response.output") - hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 + hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 || doneItems.HasItems() if output.Exists() && output.IsArray() { - if len(output.Array()) > 0 || !hasAccumulatedOutput { + terminalCount := len(output.Array()) + // A terminal output carrying at least as many items as the stream + // reported is left untouched. Carrying fewer means the terminal + // dropped items the stream already reported as done, and those + // reported items are the authoritative record of the turn. + if terminalCount > 0 && terminalCount >= doneItems.Count() { + return data, false + } + if terminalCount == 0 && !hasAccumulatedOutput { return data, false } } outputJSON := []byte("[]") - if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { + // Same precedence as reconstructResponseOutputFromSSE: the items the stream + // actually reported win over anything rebuilt from deltas. Image generation + // items arrive as done events too, so imageOutputs would duplicate them here. + if reconstructed, ok := doneItems.BuildOutput(); ok { + outputJSON = reconstructed + } else if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { outputJSON = reconstructed } updated, err := sjson.SetRawBytes(data, "response.output", outputJSON) @@ -1643,11 +2140,12 @@ func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) { } items = append(items, json.RawMessage(item.Raw)) } - forEachOpenAISSEDataPayload(bodyText, func(data []byte) { + forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) { + data = []byte(openAICompatPayloadWithEventType(string(data), eventType)) if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed { data = normalized } - if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.done" { + if eventType != "response.output_item.done" { return } appendItem(gjson.GetBytes(data, "item")) @@ -1656,8 +2154,8 @@ func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) { // compaction 只在 added 中"的混合形态;done 已含 compaction 时跳过, // 避免同一 item 在无 id 可去重时被收集两份(Codex 要求恰好一个)。 if !hasCompactionItem { - forEachOpenAISSEDataPayload(bodyText, func(data []byte) { - if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.added" { + forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) { + if eventType != "response.output_item.added" { return } item := gjson.GetBytes(data, "item") @@ -1732,11 +2230,11 @@ func responsesOutputHasCompactionItem(response []byte) bool { func findRawCompactionItemFromSSE(bodyText string) (json.RawMessage, bool) { var found json.RawMessage pick := func(eventType string) { - forEachOpenAISSEDataPayload(bodyText, func(data []byte) { + forEachOpenAISSEFrame(bodyText, func(effectiveType string, data []byte) { if found != nil { return } - if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != eventType { + if effectiveType != eventType { return } item := gjson.GetBytes(data, "item") @@ -1768,11 +2266,11 @@ func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { acc := apicompat.NewBufferedResponseAccumulator() imageOutputs := make([]json.RawMessage, 0, 1) seenImages := make(map[string]struct{}) - forEachOpenAISSEDataPayload(bodyText, func(data []byte) { + forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) { + data = []byte(openAICompatPayloadWithEventType(string(data), eventType)) if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok { imageOutputs = append(imageOutputs, imageOutput) } - eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) if responsesStreamEventMayContributeToOutput(eventType) { var event apicompat.ResponsesStreamEvent if err := json.Unmarshal(data, &event); err == nil { @@ -1835,8 +2333,8 @@ func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { usage := &OpenAIUsage{} - forEachOpenAISSEDataPayload(body, func(data []byte) { - s.parseSSEUsageBytes(data, usage) + forEachOpenAISSEFrame(body, func(eventType string, data []byte) { + s.parseSSEUsageBytesWithType(data, eventType, usage) }) return usage } diff --git a/backend/internal/service/openai_gateway_response_handling_type_test.go b/backend/internal/service/openai_gateway_response_handling_type_test.go index b783ab5c79..579224e268 100644 --- a/backend/internal/service/openai_gateway_response_handling_type_test.go +++ b/backend/internal/service/openai_gateway_response_handling_type_test.go @@ -28,7 +28,7 @@ func TestOpenAIStreamEventIsTerminalWithTypeMatchesExistingSemantics(t *testing. {name: "invalid JSON", data: `{"type":`, want: false}, {name: "terminal with trailing garbage", data: `{"type":"response.completed"} trailing`, want: true}, {name: "nonterminal with trailing garbage", data: `{"type":"response.output_text.delta"} trailing`, want: false}, - {name: "type whitespace remains nonterminal", data: `{"type":" response.completed "}`, want: false}, + {name: "type whitespace is normalized", data: `{"type":" response.completed "}`, want: true}, } for _, tt := range tests { diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go new file mode 100644 index 0000000000..423cba3459 --- /dev/null +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -0,0 +1,492 @@ +package service + +// 国产供应商 Anthropic 协议账号的 Responses 入站反向路径。 +// +// 客户端说 OpenAI Responses(/v1/responses,Codex 等)、上游是供应商原生 +// Anthropic 端点(api_protocol=anthropic)时的交叉组合:请求 Responses→Anthropic +// 单次转换,响应 Anthropic 事件→Responses 事件转换。转换链与 Anthropic 平台的 +// gateway_forward_as_responses.go 完全一致(复用同一组 apicompat 状态机),仅上游 +// 发送/错误处理对齐 OpenAI 网关语义(模型映射、failover、transport error)。 + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +// forwardResponsesViaNativeAnthropic serves OpenAI /v1/responses clients through +// a CN provider's native Anthropic endpoint. +// +// Conversion chain: +// +// Request: Responses → Anthropic (single conversion) +// Response: Anthropic events → Responses events (stream state machine) +func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + // 1. Lower Codex client-side tools to function tools understood by Anthropic. + adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to adapt request tools") + return nil, fmt.Errorf("adapt responses client tools: %w", err) + } + + // 2. Parse Responses request + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(adaptedBody, &responsesReq); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return nil, fmt.Errorf("parse responses request: %w", err) + } + originalModel := responsesReq.Model + if strings.TrimSpace(originalModel) == "" { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := responsesReq.Stream + + // 3. Convert Responses → Anthropic + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 4. Model mapping(OpenAI 网关统一入口的映射语义) + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + anthropicReq.Model = upstreamModel + + reasoningEffort := ExtractResponsesReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) + + // 5. Force upstream streaming(客户端原始终决定响应格式; + // 上游恒为流式,非流式由缓冲路径组装)。 + anthropicReq.Stream = true + reqStream := true + + logger.L().Debug("openai responses: forwarding via native anthropic endpoint", + zap.Int64("account_id", account.ID), + zap.String("original_model", originalModel), + zap.String("billing_model", billingModel), + zap.String("upstream_model", upstreamModel), + zap.Bool("client_stream", clientStream), + ) + + anthropicBody, err := json.Marshal(anthropicReq) + if err != nil { + return nil, fmt.Errorf("marshal anthropic request: %w", err) + } + + // 与 /v1/messages 直通路径相同的 pre-filter。 + anthropicBody = StripEmptyTextBlocks(anthropicBody) + anthropicBody = FilterWebSearchHistoryBlocks(anthropicBody, upstreamModel) + anthropicBody = enforceCacheControlLimit(anthropicBody) + + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if apiKey == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + targetURL, err := s.nativeAnthropicTargetURL(account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL) + releaseUpstreamCtx() + if err != nil { + return nil, fmt.Errorf("build upstream request: %w", err) + } + + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { + return nil, foErr + } + writeResponsesError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) + } + + if clientStream { + return s.handleResponsesStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping) + } + return s.handleResponsesBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping) +} + +// handleResponsesBufferedFromNativeAnthropic reads Anthropic SSE events, assembles +// the full response, then converts Anthropic → Responses. +func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + var finalResp *apicompat.AnthropicResponse + var usage ClaudeUsage + + // 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai responses via native anthropic buffered: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + logger.L().Warn("openai responses via native anthropic buffered: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream data interval timeout") + return nil, fmt.Errorf("stream data interval timeout") + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + // SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等上游返回紧凑格式。 + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + if event.Type == "message_start" && event.Message != nil { + finalResp = event.Message + mergeAnthropicUsage(&usage, event.Message.Usage) + } + if event.Type == "message_delta" { + if event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil { + finalResp.StopReason = apicompat.AnthropicStopReasonPtr(event.Delta.StopReason) + } + } + if event.Type == "content_block_start" && event.ContentBlock != nil && finalResp != nil { + finalResp.Content = append(finalResp.Content, *event.ContentBlock) + } + if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { + idx := *event.Index + if idx < len(finalResp.Content) { + switch event.Delta.Type { + case "text_delta": + finalResp.Content[idx].Text += event.Delta.Text + case "thinking_delta": + finalResp.Content[idx].Thinking += event.Delta.Thinking + case "input_json_delta": + finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) + } + } + } + } + + if finalResp == nil { + writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response") + return nil, fmt.Errorf("upstream stream ended without response") + } + + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + finalResp.Usage = apicompat.AnthropicUsage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + } + } + + responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) + responsesResp.Model = originalModel + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + // 非流式响应必须是 application/json(上游被强制流式,透传头会污染)。 + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + if respBytes, err := json.Marshal(responsesResp); err == nil { + respBytes = reverseToolNamesIfPresent(c, respBytes) + respBytes, _, err = apicompat.RestoreResponsesClientToolPayload(respBytes, clientToolMapping) + if err != nil { + return nil, fmt.Errorf("restore responses client tools: %w", err) + } + c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) + } else { + c.JSON(http.StatusOK, responsesResp) + } + + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +// handleResponsesStreamingFromNativeAnthropic reads Anthropic SSE events, converts +// each to Responses SSE events, and writes them to the client. +func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + state := apicompat.NewAnthropicEventToResponsesState() + state.Model = originalModel + clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) + + var usage ClaudeUsage + var firstTokenMs *int + firstChunk := true + clientDisconnected := false + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + resultWithUsage := func() *OpenAIForwardResult { + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, + } + } + + // 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束转换循环。上游 ctx 为 + // WithoutCancel 且 http.Client 无整体 Timeout,无此界限则 scanner.Scan() + // 永久阻塞(见 anthropic native pump 文件注释)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai responses via native anthropic stream: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + logger.L().Warn("openai responses via native anthropic stream: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + return resultWithUsage(), fmt.Errorf("stream data interval timeout") + } + + // 与 CC 姊妹路径(handleCCStreamingFromNativeAnthropic.writeChunk)同语义: + // 客户端断开后不再写出,但继续排水上游至流自然结束——Anthropic 的最终 + // output_tokens 只在末尾 message_delta 携带,提前退出会把整段生成记成 ~1 + // token,payg 上游照常计费而平台漏记。状态机照常推进以保证 finalize 一致。 + processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) { + if firstChunk { + firstChunk = false + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + if event.Type == "message_delta" && event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Type == "message_start" && event.Message != nil { + mergeAnthropicUsage(&usage, event.Message.Usage) + } + + events := apicompat.AnthropicEventToResponsesEvents(event, state) + if clientDisconnected { + return + } + for _, evt := range events { + payload, err := json.Marshal(evt) + if err != nil { + continue + } + payload = reverseToolNamesIfPresent(c, payload) + payloads, _, err := clientToolRestorer.RestoreEvent(payload) + if err != nil { + continue + } + for _, restored := range payloads { + eventType := gjson.GetBytes(restored, "type").String() + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil { + clientDisconnected = true + return + } + } + } + if len(events) > 0 { + c.Writer.Flush() + } + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + processAnthropicEvent(&event) + } + + // Finalize state machine(客户端已断开时仍推进,保证 usage 汇总完整;仅在 + // 客户端仍连接时写出)。终态帧与逐事件路径一致过工具名反转与客户端工具还原, + // 避免流截断时终态帧携带改写后的工具名。 + if finalEvents := apicompat.FinalizeAnthropicResponsesStream(state); len(finalEvents) > 0 && !clientDisconnected { + wrote := false + for _, evt := range finalEvents { + payload, err := json.Marshal(evt) + if err != nil { + continue + } + payload = reverseToolNamesIfPresent(c, payload) + payloads, _, err := clientToolRestorer.RestoreEvent(payload) + if err != nil { + continue + } + for _, restored := range payloads { + eventType := gjson.GetBytes(restored, "type").String() + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil { + clientDisconnected = true + break + } + wrote = true + } + if clientDisconnected { + break + } + } + if wrote { + c.Writer.Flush() + } + } + + return resultWithUsage(), nil +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 1082b020dd..1b4a00b09e 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "encoding/json" "errors" @@ -38,7 +39,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } clientStream := responsesReq.Stream - serviceTier := extractOpenAIServiceTierFromBody(body) // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 // tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原 @@ -49,10 +49,17 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( return nil, fmt.Errorf("resolve responses tools: %w", err) } customTools := apicompat.CustomToolNames(effectiveTools) + functionTools := apicompat.FunctionToolNames(effectiveTools) toolSearch := apicompat.HasToolSearchTool(effectiveTools) namespaceTools := apicompat.NamespaceToolNames(effectiveTools) - chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq) + // 自愈回写:历史里带明文 summary 的 reasoning item 刷新进缓存,覆盖 Redis + // 被 flush / 跨实例漂移后同 id 的 encrypted-only 副本无法再取明文的情况。 + s.recacheReasoningItemsFromInput(responsesReq.Input) + + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions(&responsesReq, &apicompat.ResponsesToChatOptions{ + ReasoningContentByID: s.reasoningContentByID, + }) if err != nil { writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, fmt.Errorf("convert responses to chat completions: %w", err) @@ -80,9 +87,10 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } - if serviceTier == nil { - serviceTier = extractOpenAIServiceTierFromBody(chatBody) - } + // 计费兜底 tier = 最终出站 body(policy filter/force 后)里的 tier;最终值由 + // resolvedOpenAIUpstreamServiceTier 决定(上游回显优先)。filter 删掉字段后 + // 这里取到 nil,不再按原请求 Fast 计费。 + serviceTier := extractOpenAIServiceTierFromBody(chatBody) logger.L().Debug("openai responses: forwarding via raw chat completions", zap.Int64("account_id", account.ID), @@ -91,6 +99,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( zap.String("upstream_model", upstreamModel), zap.Bool("stream", clientStream), ) + SetOpsUpstreamModel(c, upstreamModel) // Build and send upstream request via the shared CC pipeline apiKey, targetURL, err := s.resolveCCFallbackTarget(account) @@ -112,9 +121,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -122,6 +131,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + functionTools map[string]bool, toolSearch bool, namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, @@ -135,7 +145,8 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, functionTools, toolSearch, namespaceTools) + s.cacheReasoningItemsFromOutput(responsesResp.Output) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -149,7 +160,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil @@ -160,6 +171,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + functionTools map[string]bool, toolSearch bool, namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, @@ -173,6 +185,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) state.CustomTools = customTools + state.FunctionTools = functionTools state.ToolSearchDeclared = toolSearch state.NamespaceTools = namespaceTools clientDisconnected := false @@ -203,8 +216,10 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( c.Writer.Flush() } - scan := s.scanCCStream(resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { - writeEvents(apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state)) + scan := s.scanCCStream(c, resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { + events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state) + s.cacheReasoningItemsFromEvents(events) + writeEvents(events) }) if scan.Err != nil { @@ -215,14 +230,30 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, }, fmt.Errorf("stream usage incomplete: %w", scan.Err) } + if err := state.ValidateToolCallArguments(); err != nil { + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: scan.Usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + ReasoningEffort: reasoningEffort, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: scan.FirstTokenMs, + }, fmt.Errorf("invalid tool call arguments from upstream: %w", err) + } - writeEvents(apicompat.FinalizeChatCompletionsResponsesStream(state)) + finalEvents := apicompat.FinalizeChatCompletionsResponsesStream(state) + s.cacheReasoningItemsFromEvents(finalEvents) + writeEvents(finalEvents) if !clientDisconnected { writeStreamHeaders() if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil { @@ -243,7 +274,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -261,3 +292,100 @@ func chatChunkStartsResponsesOutput(chunk *apicompat.ChatCompletionsChunk) bool } return false } + +// responsesReasoningCacheTTL 是 reasoning 缓存(按 reasoning item id)的过期时间。 +// Codex 会话可能跨多天恢复历史,取 7 天。 +const responsesReasoningCacheTTL = 7 * 24 * time.Hour + +// reasoningContentByID 按 reasoning item id 回查缓存的 reasoning 全文,供 +// Responses→CC 桥接在客户端不回传明文 summary(encrypted-only reasoning +// item)时回注 reasoning_content。任何失败都 fail-open 返回 ""(维持桥接原 +// 行为),因为缓存只是优化而非正确性前提。 +func (s *OpenAIGatewayService) reasoningContentByID(itemID string) string { + if s == nil || s.cache == nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + content, err := s.cache.GetReasoningContent(ctx, itemID) + if err != nil { + return "" + } + return content +} + +// recacheReasoningItemsFromInput 把请求历史里带明文 summary 的 reasoning item +// 重新写入缓存(best-effort)。Codex 多数时候会原样回传明文 summary,借机 +// 刷新 TTL 并自愈 Redis 被 flush / 跨实例漂移造成的缓存缺失。 +func (s *OpenAIGatewayService) recacheReasoningItemsFromInput(inputRaw json.RawMessage) { + if s == nil || s.cache == nil { + return + } + inputRaw = bytes.TrimSpace(inputRaw) + if len(inputRaw) == 0 || inputRaw[0] != '[' { + return + } + var items []json.RawMessage + if err := json.Unmarshal(inputRaw, &items); err != nil { + return + } + for _, raw := range items { + id, text, ok := apicompat.ExtractResponsesReasoningItem(raw) + if !ok || id == "" || text == "" { + continue + } + s.setReasoningContent(id, text) + } +} + +// cacheReasoningItemsFromEvents 从 Responses 流事件里提取完成的 reasoning +// item 写入缓存(覆盖一个流中的多个 reasoning item)。 +func (s *OpenAIGatewayService) cacheReasoningItemsFromEvents(events []apicompat.ResponsesStreamEvent) { + for _, event := range events { + if event.Type != "response.output_item.done" || event.Item == nil { + continue + } + s.cacheReasoningItem(event.Item) + } +} + +// cacheReasoningItemsFromOutput 从非流式 Responses 响应的 output 里提取 +// reasoning item 写入缓存。 +func (s *OpenAIGatewayService) cacheReasoningItemsFromOutput(output []apicompat.ResponsesOutput) { + for i := range output { + s.cacheReasoningItem(&output[i]) + } +} + +func (s *OpenAIGatewayService) cacheReasoningItem(item *apicompat.ResponsesOutput) { + if item == nil || item.Type != "reasoning" || item.ID == "" { + return + } + var parts []string + for _, sum := range item.Summary { + if t := strings.TrimSpace(sum.Text); t != "" { + parts = append(parts, t) + } + } + if len(parts) == 0 { + return + } + s.setReasoningContent(item.ID, strings.Join(parts, "\n")) +} + +// setReasoningContent 写入缓存,使用 detached ctx:客户端断连后仍在 drain +// 上游流(计费需要),此时的 reasoning 也是后续轮次回注所依赖的,不能随 +// 请求 ctx 一起取消。失败仅记日志,不影响转发。 +func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) { + if s == nil || s.cache == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.cache.SetReasoningContent(ctx, itemID, content, responsesReasoningCacheTTL); err != nil { + logger.L().Warn("openai responses chat fallback: cache reasoning content failed", + zap.Error(err), + zap.String("item_id", itemID), + ) + } +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index ee484001f6..bb994b1aa4 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -9,7 +9,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/gin-gonic/gin" @@ -37,11 +39,13 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream, } + SetActualOpenAIUpstreamEndpoint(c, "/v1/responses") result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c)) require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) @@ -53,6 +57,80 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion require.False(t, result.Stream) } +// Scenario: 第三方无推理模型不收到兼容档位。 +func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists()) + require.Nil(t, result.ReasoningEffort) +} + +func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, path := range []string{"/v1/responses", "/v1/responses/compact"} { + path := path + t.Run(path, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4-channel","input":"hello","stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_mapping","object":"chat.completion","model":"gpt-5.4-account","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + account.Credentials["model_mapping"] = map[string]any{ + "gpt-5.4-channel": "gpt-5.4-account", + } + account.Credentials["compact_model_mapping"] = map[string]any{ + "gpt-5.4-account": "gpt-5.4-compact", + } + account.Extra = map[string]any{ + "openai_passthrough": true, + openai_compat.ExtraKeyResponsesSupported: false, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "gpt-5.4-account", gjson.GetBytes(upstream.lastBody, "model").String()) + }) + } +} + func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) @@ -102,6 +180,43 @@ func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t require.NotNil(t, result.FirstTokenMs) } +func TestForwardResponses_ChatFallbackRejectsInvalidToolArgumentsAtOutputLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-flash","input":"run the command","stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_length_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_length","type":"function","function":{"name":"exec_command","arguments":"{\"cmd\":\"ssh root@HOST"}}]},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_length_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":6492,"total_tokens":6496}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_length_tool"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.ErrorContains(t, err, "invalid JSON") + require.NotNil(t, result) + require.Equal(t, 4, result.Usage.InputTokens) + require.Equal(t, 6492, result.Usage.OutputTokens) + require.NotContains(t, rec.Body.String(), "response.function_call_arguments.done") + require.NotContains(t, rec.Body.String(), "response.output_item.done") + require.NotContains(t, rec.Body.String(), "data: [DONE]") +} + func TestForwardResponses_DeepSeekReasoningOnlyStreamProducesVisibleText(t *testing.T) { gin.SetMode(gin.TestMode) @@ -183,3 +298,143 @@ func forceChatResponsesFallbackAccount() *Account { } return account } + +// reasoningRecordingCache 记录 reasoning 缓存写入、并按需响应回查。 +type reasoningRecordingCache struct { + stubGatewayCache + mu sync.Mutex + sets map[string]string + getResp map[string]string +} + +func (c *reasoningRecordingCache) SetReasoningContent(_ context.Context, itemID string, content string, _ time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.sets == nil { + c.sets = make(map[string]string) + } + c.sets[itemID] = content + return nil +} + +func (c *reasoningRecordingCache) GetReasoningContent(_ context.Context, itemID string) (string, error) { + if v, ok := c.getResp[itemID]; ok { + return v, nil + } + return "", ErrReasoningContentNotFound +} + +func (c *reasoningRecordingCache) snapshotSets() map[string]string { + c.mu.Lock() + defer c.mu.Unlock() + out := make(map[string]string, len(c.sets)) + for k, v := range c.sets { + out[k] = v + } + return out +} + +// 流式响应里的 reasoning_content 应按 reasoning item id 写入缓存,供后续轮次 +// 客户端不回传明文 summary 时回注(DeepSeek thinking mode 400 修复的写入侧)。 +func TestForwardResponses_ChatFallbackCachesStreamedReasoning(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"first"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reasoning_cache_stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + cache := &reasoningRecordingCache{} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + cache: cache, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + + sets := cache.snapshotSets() + require.Len(t, sets, 1, "应恰好缓存一个 reasoning item") + for itemID, content := range sets { + require.NotEmpty(t, itemID) + require.Equal(t, "think first", content) + } +} + +// 请求侧:encrypted-only reasoning item(无明文 summary)经缓存回查补回 +// reasoning_content;带明文 summary 的 item 顺手回写缓存(自愈)。 +func TestForwardResponses_ChatFallbackRestoresReasoningFromCache(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{ + "model":"deepseek-reasoner", + "stream":false, + "input":[ + {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]}, + {"type":"function_call","call_id":"call_0","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_0","output":"ok"}, + {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ] + }`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_reasoning_cache_restore"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_restore","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`, + )), + }} + cache := &reasoningRecordingCache{ + getResp: map[string]string{"item_enc1": "cached thinking"}, + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + cache: cache, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + + // 明文 summary 的 assistant 工具调用消息:reasoning_content 来自 summary 本身。 + require.Equal(t, "plain thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String()) + require.Equal(t, "call_0", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String()) + require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.1.role").String()) + // encrypted-only 的 assistant 工具调用消息:reasoning_content 来自缓存回查。 + require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.2.reasoning_content").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.2.tool_calls.0.id").String()) + require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.3.role").String()) + + // 明文 summary 的 item 被回写进缓存(自愈)。 + require.Equal(t, "plain thinking", cache.snapshotSets()["item_plain"]) +} diff --git a/backend/internal/service/openai_gateway_responses_client_tools_test.go b/backend/internal/service/openai_gateway_responses_client_tools_test.go new file mode 100644 index 0000000000..fa4866decf --- /dev/null +++ b/backend/internal/service/openai_gateway_responses_client_tools_test.go @@ -0,0 +1,280 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func openAIClientToolsRequest(stream bool) []byte { + streamValue := "false" + if stream { + streamValue = "true" + } + return []byte(`{"model":"gpt-5.4","input":"fix it","stream":` + streamValue + `,"tools":[{"type":"custom","name":"exec"},{"type":"custom","name":"apply_patch"}]}`) +} + +func assertOpenAIClientToolsLowered(t *testing.T, body []byte) { + t.Helper() + for index, name := range []string{"exec", "apply_patch"} { + tool := gjson.GetBytes(body, "tools."+string(rune('0'+index))) + require.Equal(t, "function", tool.Get("type").String()) + require.Equal(t, name, tool.Get("name").String()) + require.Equal(t, "string", tool.Get("parameters.properties.input.type").String()) + } +} + +func openAIClientToolsTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService { + return &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }}, + } +} + +func TestAdaptOpenAIResponsesClientToolsLeavesNamespaceOnlyBodyUnchanged(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.5", + "tools": [{"type": "namespace", "name": "code_tools", "tools": [{"type": "function", "name": "run"}]}], + "tool_choice": "auto" + }`) + + adapted, mapping, err := adaptOpenAIResponsesClientTools(body) + + require.NoError(t, err) + require.Equal(t, body, adapted) + require.Empty(t, mapping.CustomTools) + require.Empty(t, mapping.NamespaceTools) + require.False(t, mapping.ToolSearch) +} + +func TestAdaptOpenAIResponsesClientToolsRejectsTrailingData(t *testing.T) { + tests := map[string][]byte{ + "trailing garbage": append(openAIClientToolsRequest(false), []byte(` garbage`)...), + "second JSON document": append(openAIClientToolsRequest(false), []byte(` {"model":"other"}`)...), + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + adapted, mapping, err := adaptOpenAIResponsesClientTools(body) + + require.ErrorContains(t, err, "decode OpenAI Responses client tools trailing data") + require.Equal(t, body, adapted) + require.Empty(t, mapping) + }) + } +} + +func TestResponsesFunctionUpstreamsLowerToolSearchDiscoveryOutput(t *testing.T) { + body := []byte(`{"tools":[{"type":"tool_search"}],"input":[{"type":"tool_search_output","call_id":"search_1","tools":[{"type":"namespace","name":"github"}],"status":"completed","execution":"client"}]}`) + adapters := map[string]func([]byte) ([]byte, apicompat.ResponsesClientToolMapping, error){ + "OpenAI API-key": adaptOpenAIResponsesClientTools, + "Grok": adaptGrokResponsesClientTools, + } + for name, adapt := range adapters { + t.Run(name, func(t *testing.T) { + adapted, mapping, err := adapt(body) + require.NoError(t, err) + require.True(t, mapping.ToolSearch) + require.Equal(t, "function_call_output", gjson.GetBytes(adapted, "input.0.type").String()) + require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, gjson.GetBytes(adapted, "input.0.output").String()) + require.False(t, gjson.GetBytes(adapted, "input.0.tools").Exists()) + require.False(t, gjson.GetBytes(adapted, "input.0.status").Exists()) + require.False(t, gjson.GetBytes(adapted, "input.0.execution").Exists()) + }) + } +} + +func TestClearOpenAIResponsesClientToolMappingRemovesStaleContextState(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}) + + clearOpenAIResponsesClientToolMapping(c) + + _, ok := openAIResponsesClientToolMapping(c) + require.False(t, ok) +} + +func TestDeepSeekResponsesForwardRestoresClientToolsStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"exec","status":"in_progress"}}`, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"item_id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}`, + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}}`, + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_ds_tools","status":"completed","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + }, "\n\n") + "\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := openAIClientToolsTestService(upstream) + account := &Account{ + ID: 5661, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "api_protocol": APIProtocolResponses, + "base_url": "https://relay.example", + }, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + assertOpenAIClientToolsLowered(t, upstream.lastBody) + require.Equal(t, "/responses", upstream.lastReq.URL.Path) + output := recorder.Body.String() + require.Contains(t, output, `"type":"custom_tool_call"`) + require.Contains(t, output, `"type":"response.custom_tool_call_input.done"`) + require.Contains(t, output, `"input":"pwd"`) +} + +func TestDeepSeekAdaptiveResponsesForwardRestoresClientToolsNonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(false) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_ds_adaptive_tools","status":"completed","output":[ + {"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}, + {"type":"function_call","id":"i2","call_id":"c2","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}], + "usage":{"input_tokens":1,"output_tokens":1}}`)), + }} + svc := openAIClientToolsTestService(upstream) + account := &Account{ + ID: 5662, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolResponses: "https://relay.example", + }, + }, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + assertOpenAIClientToolsLowered(t, upstream.lastBody) + require.Equal(t, "/responses", upstream.lastReq.URL.Path) + require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.0.type").String()) + require.Equal(t, "pwd", gjson.Get(recorder.Body.String(), "output.0.input").String()) + require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.1.type").String()) + require.Equal(t, "*** Begin Patch", gjson.Get(recorder.Body.String(), "output.1.input").String()) +} + +func TestDeepSeekResponsesCompactSkipsClientToolAdaptation(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(false) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), + }} + svc := openAIClientToolsTestService(upstream) + account := &Account{ + ID: 5663, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "api_protocol": APIProtocolResponses, + "base_url": "https://relay.example", + }, + } + + _, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, "custom", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) + require.Equal(t, "/responses/compact", upstream.lastReq.URL.Path) +} + +func TestOpenAIPassthroughAPIKeyRestoresClientToolsNonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(false) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_tools","status":"completed","output":[ + {"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}, + {"type":"function_call","id":"i2","call_id":"c2","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],"usage":{}}`)), + }} + svc := openAIClientToolsTestService(upstream) + account := &Account{ID: 5659, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}} + + result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, false, time.Now()) + + require.NoError(t, err) + require.NotNil(t, result) + assertOpenAIClientToolsLowered(t, upstream.lastBody) + require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.0.type").String()) + require.Equal(t, "pwd", gjson.Get(recorder.Body.String(), "output.0.input").String()) + require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.1.type").String()) + require.Equal(t, "*** Begin Patch", gjson.Get(recorder.Body.String(), "output.1.input").String()) +} + +func TestOpenAIPassthroughAPIKeyRestoresClientToolsStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","status":"in_progress"}}`, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"item_id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}`, + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}","status":"completed"}}`, + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_stream_tools","status":"completed","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + }, "\n\n") + "\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(sse))}} + svc := openAIClientToolsTestService(upstream) + account := &Account{ID: 5660, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}} + + result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, true, time.Now()) + + require.NoError(t, err) + require.NotNil(t, result) + assertOpenAIClientToolsLowered(t, upstream.lastBody) + output := recorder.Body.String() + require.Contains(t, output, `"type":"custom_tool_call"`) + require.Contains(t, output, `"type":"response.custom_tool_call_input.done"`) + require.Contains(t, output, `"input":"*** Begin Patch"`) + require.NotContains(t, output, `"input":{`) +} diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index be25b72b01..a90bad3e25 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -28,6 +28,7 @@ const ( ) var explicitOpenAIHeaderSessionNames = []string{ + "session-id", "session_id", "conversation_id", openCodeSessionAffinityHeader, @@ -66,11 +67,25 @@ func explicitOpenAISessionID(c *gin.Context, body []byte) string { sessionID := explicitOpenAIHeaderSessionID(c) if sessionID == "" && len(body) > 0 { - sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) + sessionID = strings.TrimSpace(openAIRequestPayloadView(body).Get("prompt_cache_key").String()) } return sessionID } +// openAIRequestPayloadView unwraps Responses WebSocket event envelopes while +// leaving ordinary HTTP objects untouched even when they contain a response +// field for another purpose. +func openAIRequestPayloadView(body []byte) gjson.Result { + root := parseRawJSONView(body) + eventType := strings.ToLower(strings.TrimSpace(root.Get("type").String())) + if strings.HasPrefix(eventType, "response.") { + if response := root.Get("response"); response.Exists() && response.IsObject() { + return response + } + } + return root +} + // explicitOpenAIRequestSessionID extends the common OpenAI session signals // with Grok's native conversation header only for requests authenticated to a // Grok group. This keeps an unrelated x-grok-conv-id header from changing @@ -91,7 +106,7 @@ func explicitOpenAIRequestSessionID(c *gin.Context, body []byte) string { sessionID = strings.TrimSpace(c.GetHeader(grokConversationIDHeader)) } if sessionID == "" && len(body) > 0 { - sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) + sessionID = strings.TrimSpace(openAIRequestPayloadView(body).Get("prompt_cache_key").String()) } if sessionID == "" && isGrokRequestContext(c) && len(body) > 0 { sessionID = grokPreviousResponseSessionSeed(body) @@ -131,7 +146,7 @@ func (s *OpenAIGatewayService) GenerateExplicitSessionHash(c *gin.Context, body // GenerateSessionHash generates a sticky-session hash for OpenAI requests. // // Priority: -// 1. Header: session_id +// 1. Header: session-id / session_id // 2. Header: conversation_id // 3. Header: x-session-affinity / x-session-id / x-opencode-session (OpenCode) // 4. Header: x-conversation-id (CodeBuddy) @@ -243,15 +258,49 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C return s.selectAccountForModelWithExclusions(s.withOpenAIQuotaAutoPauseContext(ctx), groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, 0, "", false) } -// noAvailableOpenAISelectionError builds the standard "no account available" error -// while preserving the compact-specific error when applicable. -func normalizeOpenAICompatiblePlatform(platform string) string { - if platform == PlatformGrok { - return PlatformGrok - } - return PlatformOpenAI +// SelectAccountForTokenCount selects an account for a non-billable token-count +// request. It applies the normal platform, model, capability, and runtime +// eligibility checks without acquiring or waiting for a generation slot. +func (s *OpenAIGatewayService) SelectAccountForTokenCount( + ctx context.Context, + groupID *int64, + sessionHash string, + requestedModel string, + requiredCapability OpenAIEndpointCapability, + platform string, +) (*Account, error) { + ctx = WithOpenAIProfitControlSuppressed(ctx) + ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + return s.selectAccountForModelWithExclusions( + ctx, + groupID, + platform, + sessionHash, + requestedModel, + nil, + false, + 0, + requiredCapability, + false, + ) } +// NormalizeOpenAICompatiblePlatform 保留 grok 与国产 OpenAI 兼容供应商(kimi/zhipu/ +// deepseek)的原值,其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: +// kimi 分组请求只命中 kimi 账号,语义与 openai/grok 一致。 +// (upstream 曾将本函数改为未导出 normalizeOpenAICompatiblePlatform,本分支的 +// handler 调度入口仍需导出,保持导出名。) +func NormalizeOpenAICompatiblePlatform(platform string) string { + switch platform { + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: + return platform + default: + return PlatformOpenAI + } +} + +// noAvailableOpenAISelectionError builds the standard "no account available" error +// while preserving the legacy /responses/compact error when applicable. // details carries an optional machine-parseable exclusion summary (e.g. // "pool=2, filtered: quota_auto_pause_7d=1 runtime_blocked=1") appended in // parentheses. It is for server-side logs / ops diagnostics only: handlers @@ -312,24 +361,45 @@ func openAICompactSupportTier(account *Account) int { // 注意:对 spark 影子账号,调用方还须额外调用 parentHealthyForShadow(account, lookup) // 检查母账号凭据可用性;该检查未内置于本函数,以避免注入 DB 依赖。 func isOpenAICompatibleAccountEligibleForRequest(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { - if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { - return false + return openAICompatibleAccountEligibilityFailureReason(ctx, account, platform, requestedModel, requireCompact, requiredCapability) == "" +} + +// openAICompatibleAccountEligibilityFailureReason mirrors the legacy boolean +// eligibility check while naming its first veto point. Load-batch selection uses +// the reason only for server-side no-account diagnostics; the admission behavior +// remains unchanged. +func openAICompatibleAccountEligibilityFailureReason(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) string { + if reason := openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability); reason != "" { + return reason } // 分组利润控制:legacy 引擎的粘性/候选循环与 DB recheck 共用 // 本判定,任何 fallback 都不能把利润不合格账号重新放回候选。 - if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { - return false + if vetoed, reason := openAIProfitControlVetoReason(ctx, account); vetoed { + return reason } - return true + return "" } // isOpenAICompatibleAccountEligibleForRequestBeforeProfit applies every // ordinary scheduling gate. Legacy selection uses it before classifying the // profit veto so earlier failures retain their actual reason. func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { - platform = normalizeOpenAICompatiblePlatform(platform) - if account == nil || account.Platform != platform || !account.IsOpenAICompatible() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) { - return false + return openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) == "" +} + +func openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) string { + platform = NormalizeOpenAICompatiblePlatform(platform) + if account == nil { + return "account_nil" + } + if account.Platform != platform || !account.IsOpenAICompatible() { + return "platform_mismatch" + } + if !account.IsSchedulableForModelWithContext(ctx, requestedModel) { + if account.IsSchedulable() { + return "model_rate_limited" + } + return "not_schedulable" } if account.IsOpenAI() { if paused, reason := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { @@ -341,7 +411,10 @@ func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context "threshold", reason.threshold, "utilization", reason.utilization, ) - return false + if reason.window != "" { + return "quota_auto_pause_" + reason.window + } + return "quota_auto_pause" } } if account.IsGrok() { @@ -352,29 +425,33 @@ func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context "threshold", reason.threshold, "utilization", reason.utilization, ) - return false + if reason.window != "" { + return "quota_auto_pause_" + reason.window + } + return "quota_auto_pause" } } if requestedModel != "" && !account.IsModelSupported(requestedModel) { - return false + return "model_not_supported" } if !account.SupportsOpenAIEndpointCapability(requiredCapability) { if account.IsGrok() && requiredCapability == OpenAIEndpointCapabilityGrokMediaGeneration { _, reason := account.GrokMediaGenerationEligibility() slog.Debug("grok_media_account_ineligible", "account_id", account.ID, "reason", reason) } - return false + return "capability_mismatch" } if requireCompact && openAICompactSupportTier(account) == 0 { - return false + return "compact_unsupported" } - return true + return "" } type openAIQuotaAutoPauseDecision struct { window string threshold float64 utilization float64 + reason string } func shouldAutoPauseGrokAccountByQuota(account *Account) (bool, openAIQuotaAutoPauseDecision) { @@ -445,6 +522,38 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) if account == nil || !account.IsOpenAI() { return false, openAIQuotaAutoPauseDecision{} } + // 自动用卡有独立阈值:达到消费阈值时必须先退出调度;仅达到普通暂停阈值时, + // 只有新鲜状态明确存在可用卡才继续放行到消费阈值。 + if config := ResolveOpenAIAutoResetCreditConfig(account); config.Enabled { + now := time.Now() + utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) + utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) + if has5h && utilization5h >= config.Threshold5h { + notifyOpenAIAutoReset(account.ID) + return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"} + } + if has7d && utilization7d >= config.Threshold7d { + notifyOpenAIAutoReset(account.ID) + return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"} + } + + disabled5h := resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") + disabled7d := resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") + pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(ctx, account) + pauseReached5h := !disabled5h && pause5h > 0 && has5h && utilization5h >= pause5h + pauseReached7d := !disabled7d && pause7d > 0 && has7d && utilization7d >= pause7d + if pauseReached5h || pauseReached7d { + state := openAIAutoResetStateFromExtra(account.Extra) + if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) { + return false, openAIQuotaAutoPauseDecision{} + } + notifyOpenAIAutoReset(account.ID) + if pauseReached5h { + return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"} + } + return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: pause7d, utilization: utilization7d, reason: "quota_auto_reset_credit_check_7d"} + } + } // Per-account explicit-disable flags must take precedence over the global default. // Without these, leaving the account threshold blank means "use global default", // so an admin has no way to exempt a single account from auto-pause once a global @@ -685,21 +794,92 @@ func prioritizeOpenAICompactAccounts(accounts []*Account) []*Account { } // resolveOpenAIAccountUpstreamModelForRequest resolves the upstream model that -// would be sent for a given request, honouring compact-only mappings when the -// caller is on the /responses/compact path. +// would be sent for a given request, honoring the legacy compact-only mapping +// when the caller is on the /responses/compact path. func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string { + // Forward checks the raw Chat Completions fallback before passthrough. + // These API-key accounts therefore apply normal account model_mapping and + // upstream normalization, but never compact_model_mapping. + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "") + return normalizeOpenAIModelForUpstream(account, upstreamModel) + } + + // Passthrough accounts only replace authentication. Their Forward path + // keeps the channel-mapped model in the request body and does not apply the + // account's normal model_mapping. Legacy /responses/compact is the one + // exception: forwardOpenAIPassthrough applies compact_model_mapping + // directly to that channel-mapped model. + if account != nil && account.IsOpenAIPassthroughEnabled() { + upstreamModel := strings.TrimSpace(requestedModel) + if upstreamModel == "" { + return "" + } + if requireCompact { + return resolveOpenAICompactForwardModel(account, upstreamModel) + } + return upstreamModel + } + + // Compact mappings are keyed by the client-visible model. Prefer an exact + // compact rule before ordinary account mapping; otherwise a normal alias can + // hide the compact-specific rule and make scheduling disagree with Forward. + if requireCompact && account != nil { + if compactModel, matched := account.ResolveCompactMappedModel(strings.TrimSpace(requestedModel)); matched { + if compactModel = strings.TrimSpace(compactModel); compactModel != "" { + return compactModel + } + } + } + upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "") if upstreamModel == "" { return "" } if requireCompact { - return resolveOpenAICompactForwardModel(account, upstreamModel) + compactModel := resolveOpenAICompactForwardModel(account, upstreamModel) + if compactModel != upstreamModel { + return compactModel + } } - return upstreamModel + return normalizeOpenAIModelForUpstream(account, upstreamModel) +} + +// ResolveOpenAIAccountUpstreamModelForRequest exposes the scheduler's exact +// account mapping chain to handler-side outcome reporting. +func ResolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string { + return resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact) +} + +// resolveOpenAIForwardMappedModels is the shared account mapping chain for +// Forward callers. billingModel retains the ordinary mapping used for usage +// accounting, while upstreamModel is the model the scheduler has admitted. +func resolveOpenAIForwardMappedModels(account *Account, requestedModel string, requireCompact bool) (billingModel, upstreamModel string) { + requestedModel = strings.TrimSpace(requestedModel) + if account != nil && account.IsOpenAIPassthroughEnabled() { + billingModel = requestedModel + } else if account != nil { + billingModel = strings.TrimSpace(account.GetMappedModel(requestedModel)) + } + if billingModel == "" { + billingModel = requestedModel + } + upstreamModel = resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact) + if strings.TrimSpace(upstreamModel) == "" { + upstreamModel = billingModel + } + return billingModel, upstreamModel +} + +func resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel string) string { + if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { + return upstreamModel + } + return strings.TrimSpace(billingModel) } func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, error) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), @@ -752,7 +932,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID if sessionHash == "" { return nil } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) accountID := stickyAccountID if accountID <= 0 { @@ -815,10 +995,11 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID // selectBestAccount selects the best account from candidates (priority + LRU). // Returns nil if no available account. The second return reports whether at // least one candidate was filtered out solely because it lacks compact support -// (only meaningful when requireCompact=true); the third contains deterministic +// (only meaningful when the legacy /responses/compact requireCompact flag is +// true); the third contains deterministic // exclusion diagnostics for the evaluated snapshot. func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, platform string, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, bool, openAISelectionFilterStats) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) compactBlocked := false filterStats := openAISelectionFilterStats{pool: len(accounts)} needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) @@ -923,6 +1104,7 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool // SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan. func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + ctx = s.withOpenAIGroupPrivacyRequirement(ctx, groupID) // 分组利润控制:legacy 公共入口同样装门,保证不经 // selectAccountWithScheduler 的调用方也无法绕过利润准入。 ctx = s.withOpenAIProfitControlGate(ctx, groupID) @@ -930,7 +1112,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex } func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability, useUpstreamTokenCost bool) (*AccountSelectionResult, error) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), @@ -980,7 +1162,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex return nil, err } if len(accounts) == 0 { - return nil, ErrNoAvailableAccounts + return nil, noAvailableOpenAISelectionError(requestedModel, false, openAISelectionFilterStats{}.summary("")) } isExcluded := func(accountID int64) bool { @@ -992,6 +1174,11 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } // ============ Layer 1: Sticky session ============ + // A healthy sticky account whose bounded wait queue is full may be used as a + // one-request capacity spillover in Layer 2. Keep that spillover temporary: + // rewriting the durable binding here would make a short burst migrate the + // whole conversation to a cache-cold account. + stickySpillover := false if sessionHash != "" { accountID := stickyAccountID if accountID > 0 && !isExcluded(accountID) { @@ -1033,6 +1220,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex MaxWaiting: cfg.StickySessionMaxWaiting, }) } + stickySpillover = true } } } @@ -1055,25 +1243,31 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex return a } baseCandidateCount := 0 + filterStats := openAISelectionFilterStats{pool: len(accounts)} candidates := make([]*Account, 0, len(accounts)) for i := range accounts { acc := &accounts[i] if isExcluded(acc.ID) { + filterStats.exclude("excluded") continue } // Scheduler snapshots can be temporarily stale (bucket rebuild is throttled); // re-check schedulability here so recently rate-limited/overloaded accounts // are not selected again before the bucket is rebuilt. - if !isOpenAICompatibleAccountEligibleForRequest(ctx, acc, platform, requestedModel, false, requiredCapability) { + if reason := openAICompatibleAccountEligibilityFailureReason(ctx, acc, platform, requestedModel, false, requiredCapability); reason != "" { + filterStats.exclude(reason) continue } if !parentHealthyForShadow(acc, parentLookupL2) { + filterStats.exclude("shadow_parent_unhealthy") continue } if s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel) { + filterStats.exclude("runtime_blocked") continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) { + filterStats.exclude("channel_upstream_restricted") continue } baseCandidateCount++ @@ -1081,7 +1275,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } if len(candidates) == 0 { - return nil, ErrNoAvailableAccounts + return nil, noAvailableOpenAISelectionError(requestedModel, false, filterStats.summary("")) } rateOrder := openAILegacyUpstreamRateOrder{} if preferLowUpstreamRate { @@ -1178,7 +1372,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if selectErr != nil { return nil, true, selectErr } - if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { + if sessionHash != "" && !stickySpillover && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } return selection, true, nil @@ -1217,7 +1411,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if selectErr != nil { return nil, selectErr } - if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { + if sessionHash != "" && !stickySpillover && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } return selection, nil @@ -1276,7 +1470,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string) ([]Account, error) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot != nil { accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, false) if err != nil { @@ -1329,7 +1523,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit( if account == nil { return nil } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) fresh := account if s.schedulerSnapshot != nil { @@ -1387,8 +1581,11 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct if account == nil { return nil } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot == nil || s.accountRepo == nil { + if s.openAIGroupRequiresPrivacySet(ctx, groupID) && !account.IsPrivacySet() { + return nil + } if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { return nil } @@ -1411,6 +1608,9 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct if !s.openAIAccountMatchesSchedulingGroup(latest, groupID) { return nil } + if s.openAIGroupRequiresPrivacySet(ctx, groupID) && !latest.IsPrivacySet() { + return nil + } if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { return nil } diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go index 2633a5b930..d2d069ff5c 100644 --- a/backend/internal/service/openai_gateway_search_surcharge_test.go +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -5,6 +5,7 @@ package service import ( "context" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -37,6 +38,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + time.Time{}, ) require.NoError(t, err) require.NotNil(t, cost) @@ -67,6 +69,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing. UsageTokens{}, "", boolPtr(false), + time.Time{}, ) require.NoError(t, err) require.NotNil(t, cost) @@ -112,6 +115,7 @@ func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + time.Time{}, ) require.Error(t, err) require.Nil(t, cost) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 55353e1d0a..5ce98fba0f 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -247,11 +247,14 @@ type OpenAIForwardResult struct { // response before any client-facing rewrite or protocol conversion. UpstreamResponseModel string UpstreamResponseModelConflict bool + // UpstreamResponseServiceTier is the tier the upstream reports having used + // (response service_tier: "priority" / "default" / "flex" / ...); "" when not declared. + UpstreamResponseServiceTier string // UpstreamEndpoint is the actual upstream API path used for this request. // It avoids guessing when one downstream protocol can use multiple upstream endpoints. UpstreamEndpoint string - // ServiceTier records the OpenAI Responses API service tier, e.g. "priority" / "flex". - // Nil means the request did not specify a recognized tier. + // ServiceTier 优先取上游实际响应回显的 tier;缺失时回退到最终出站 body 的 + // tier。nil 表示两者都无识别 tier。 ServiceTier *string // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. // Stored for usage records display; nil means not provided / not applicable. @@ -284,8 +287,9 @@ type OpenAIForwardResult struct { // AudioUsage carries Voice billing units when present. AudioUsage *AudioUsage - wsReplayInput []json.RawMessage - wsReplayInputExists bool + wsReplayInput []json.RawMessage + wsReplayInputExists bool + wsAccountFailoverReplayInput []json.RawMessage } // SucceededForScheduling reports whether this result is an upstream success @@ -315,6 +319,16 @@ func SetActualOpenAIUpstreamEndpoint(c *gin.Context, endpoint string) { } } +// ClearActualOpenAIUpstreamEndpoint 清理当前转发尝试记录的端点。 +// Handler 会在账号 failover 尝试间复用同一个 Gin context,因此每次尝试 +// 都必须从无残留状态开始。 +func ClearActualOpenAIUpstreamEndpoint(c *gin.Context) { + if c == nil { + return + } + c.Set(openAIUpstreamEndpointContextKey, "") +} + // GetActualOpenAIUpstreamEndpoint returns the endpoint recorded by the latest // forwarding attempt in this request. func GetActualOpenAIUpstreamEndpoint(c *gin.Context) string { @@ -398,8 +412,8 @@ func (t *accountWriteThrottle) Allow(id int64, now time.Time) bool { var defaultOpenAICodexSnapshotPersistThrottle = newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval) -// ErrNoAvailableCompactAccounts indicates the request needs /responses/compact -// support but no compatible account is available. +// ErrNoAvailableCompactAccounts indicates a legacy /responses/compact request +// needs compact support but no compatible account is available. var ErrNoAvailableCompactAccounts = errors.New("no available accounts support /responses/compact") // OpenAIGatewayService handles OpenAI API gateway operations @@ -419,6 +433,7 @@ type OpenAIGatewayService struct { billingCacheService *BillingCacheService userGroupRateResolver *userGroupRateResolver httpUpstream HTTPUpstream + pluginManager *PluginManager deferredService *DeferredService openAITokenProvider *OpenAITokenProvider grokTokenProvider *GrokTokenProvider @@ -443,6 +458,7 @@ type OpenAIGatewayService struct { openaiWSStateStore OpenAIWSStateStore openaiScheduler OpenAIAccountScheduler openaiWSPassthroughDialer openAIWSClientDialer + openaiWSSessionPreemptions openAIWSSessionPreemptRegistry openaiAccountStats *openAIAccountRuntimeStats openaiModelTransient *openAIAccountModelTransientState openaiProxyStreamCircuit *openAIProxyStreamCircuit @@ -453,6 +469,7 @@ type OpenAIGatewayService struct { openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64 openaiAccountRuntimeBlockSequence atomic.Uint64 + openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex openaiOAuth429WindowStartUnixNano atomic.Int64 openaiOAuth429WindowCount atomic.Int64 @@ -462,6 +479,11 @@ type OpenAIGatewayService struct { codexModelsManifestCache codexModelsManifestCache openaiCompatSessionResponses sync.Map openaiCompatAnthropicDigestSessions sync.Map + // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin, + // 记录最近一次向该会话下发 x-codex-turn-state 的铸造账号,供出站守卫 + // 剥离跨账号回带(openai_codex_turn_state.go)。 + openaiCodexTurnStateOrigins sync.Map + openaiCodexTurnStateWrites atomic.Uint64 } // NewOpenAIGatewayService creates a new OpenAIGatewayService @@ -598,6 +620,10 @@ func (s *OpenAIGatewayService) isUpstreamModelRestrictedByChannel(ctx context.Co if s.channelService == nil { return false } + if compactForwardModel, ok := openAIForwardModelFromContext(ctx); ok { + requestedModel = compactForwardModel.model + requireCompact = compactForwardModel.useCompactModelMapping + } upstreamModel := resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact) if upstreamModel == "" { return false @@ -1188,6 +1214,17 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco return "", "", errors.New("access_token not found in credentials") } return accessToken, "oauth", nil + case AccountTypeSetupToken: + if !account.IsOpenAIOAuthLike() { + return "", "", fmt.Errorf("unsupported account type: %s", account.Type) + } + // OpenAI setup tokens are inference-only bearer credentials. They use the + // Codex OAuth forwarding protocol but have no refresh-token lifecycle. + accessToken := account.GetOpenAIAccessToken() + if accessToken == "" { + return "", "", errors.New("access_token not found in credentials") + } + return accessToken, "oauth", nil case AccountTypeAPIKey: if account.Platform == PlatformGrok { apiKey := strings.TrimSpace(account.GetCredential("api_key")) @@ -1196,7 +1233,7 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco } return apiKey, "apikey", nil } - apiKey := account.GetOpenAIApiKey() + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return "", "", errors.New("api_key not found in credentials") } diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 846529828b..850c5d0ce8 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -287,6 +287,24 @@ func TestIsOpenAITransientProcessingError(t *testing.T) { []byte(`{"error":{"code":"slow_down","message":"Please retry later."}}`), )) + require.True(t, isOpenAITransientProcessingError( + http.StatusBadRequest, + "", + []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`), + )) + + require.True(t, isOpenAITransientProcessingError( + http.StatusServiceUnavailable, + "Server is overloaded. Please try again later.", + nil, + )) + + require.True(t, isOpenAITransientProcessingError( + http.StatusBadGateway, + "", + []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`), + )) + require.True(t, isOpenAITransientProcessingError( http.StatusBadRequest, "", @@ -309,10 +327,29 @@ func TestIsOpenAIContextWindowError(t *testing.T) { "maximum context length exceeded", nil, )) + require.True(t, isOpenAIContextWindowError( + "", + []byte(`maximum context length exceeded`), + )) require.False(t, isOpenAIContextWindowError( "context canceled", nil, )) + require.False(t, isOpenAIContextWindowError( + "upstream unavailable", + []byte(`{"error":{"message":"upstream unavailable","code":"upstream_error"},"echo":"context_length_exceeded maximum context length"}`), + )) +} + +func TestOpenAITransientAndCapacityClassificationIgnoresEchoedJSON(t *testing.T) { + body := []byte(`{"error":{"message":"upstream unavailable","code":"upstream_error"},"echo":"server is overloaded; selected model is at capacity"}`) + + require.False(t, isOpenAITransientProcessingError(http.StatusBadRequest, "upstream unavailable", body)) + require.False(t, isOpenAIRequestScopedCapacityShed("upstream unavailable", body)) + + plainText := []byte(`server is overloaded; please retry later`) + require.True(t, isOpenAITransientProcessingError(http.StatusServiceUnavailable, "", plainText)) + require.True(t, isOpenAIRequestScopedCapacityShed("", plainText)) } func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) { @@ -321,6 +358,11 @@ func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) { require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "", body)) require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`))) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse( + http.StatusBadGateway, + "temporary upstream outage", + []byte(`{"error":{"message":"temporary upstream outage"},"echo":"context_length_exceeded"}`), + )) } func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index a51f58bf56..381812faec 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -719,7 +719,7 @@ func TestOpenAIGatewayService_Forward_CodexBridgeInjectionSetsImageBilling(t *te require.Equal(t, "gpt-image-2", result.BillingModel) } -func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t *testing.T) { +func TestOpenAIGatewayService_Forward_HTTPPreservesPreviousResponseIDForAPIKey(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false @@ -756,7 +756,7 @@ func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) - require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) + require.True(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) } } @@ -920,10 +920,17 @@ func TestExtractOpenAIReasoningEffortFromBody(t *testing.T) { wantValue: "xhigh", }, { - name: "DeepSeek max 归一化为 xhigh", + name: "DeepSeek V4 保留 max", body: []byte(`{"reasoning_effort":"max"}`), model: "deepseek-v4-pro", wantNil: false, + wantValue: "max", + }, + { + name: "旧模型仍将 max 归一化为 xhigh", + body: []byte(`{"reasoning_effort":"max"}`), + model: "gpt-5.5", + wantNil: false, wantValue: "xhigh", }, { diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index e7ddaca80e..568c948627 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -115,7 +115,7 @@ func (r stubOpenAIAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Co return r.ListSchedulableByPlatform(ctx, platform) } -func TestOpenAIGatewayService_ForwardAsAnthropic_TempUnschedulableReturnsFailoverWithoutCommit(t *testing.T) { +func TestOpenAIGatewayService_ForwardAsAnthropic_CapacityShedReturnsRequestScopedFailoverWithoutCommit(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) @@ -173,8 +173,10 @@ func TestOpenAIGatewayService_ForwardAsAnthropic_TempUnschedulableReturnsFailove require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode) require.True(t, failoverErr.ShouldRetryNextAccount()) - require.Equal(t, account.ID, repo.modelRateLimitAccountID, "temporary unschedulability should exclude this account from reselection") - require.Equal(t, "gpt-5.4", repo.modelRateLimitKey) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.Zero(t, repo.modelRateLimitAccountID, "request-scoped capacity shedding must not change account health") + require.Empty(t, repo.modelRateLimitKey) require.False(t, IsResponseCommitted(c)) require.Equal(t, http.StatusOK, rec.Code) require.Empty(t, rec.Body.String()) @@ -204,17 +206,17 @@ func TestFailoverOpenAIUpstreamHTTPError_NilContextSkipsTempUnschedulablePolicy( "temp_unschedulable_enabled": true, "temp_unschedulable_rules": []any{map[string]any{ "error_code": float64(http.StatusBadRequest), - "keywords": []any{"servers are currently overloaded"}, + "keywords": []any{"custom temporary outage"}, "duration_minutes": float64(1), }}, }, } - body := []byte(`{"error":{"message":"Our servers are currently overloaded."}}`) + body := []byte(`{"error":{"message":"Custom temporary outage."}}`) resp := &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}} got := svc.failoverOpenAIUpstreamHTTPError( context.Background(), nil, account, resp, body, - "Our servers are currently overloaded.", "gpt-5.4", + "Custom temporary outage.", "gpt-5.4", ) require.Nil(t, got) @@ -389,6 +391,7 @@ func TestOpenAIGatewayService_ClientSessionHeaderPriority(t *testing.T) { name string value string }{ + {name: "session-id", value: "codex-session"}, {name: "session_id", value: "generic-session"}, {name: "conversation_id", value: "generic-conversation"}, {name: openCodeSessionAffinityHeader, value: "opencode-affinity"}, @@ -414,6 +417,31 @@ func TestOpenAIGatewayService_ClientSessionHeaderPriority(t *testing.T) { require.Equal(t, "body-session", svc.ExtractSessionID(c, body)) } +func TestOpenAIGatewayService_CodexSessionIDKeepsReconnectHashStable(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("session-id", "codex-reconnect-session") + + svc := &OpenAIGatewayService{} + warmup := []byte(`{ + "type":"response.create", + "model":"gpt-5.6-sol", + "generate":false, + "tools":[{"type":"custom","name":"exec"}], + "input":[{"role":"user","content":"warmup"}] + }`) + business := []byte(`{ + "type":"response.create", + "model":"gpt-5.6-sol", + "input":[{"role":"user","content":"install codex"}] + }`) + + require.Equal(t, svc.GenerateSessionHash(c, warmup), svc.GenerateSessionHash(c, business)) + require.Equal(t, "codex-reconnect-session", svc.ExtractSessionID(c, business)) +} + func TestOpenAIGatewayService_ClientSessionHeadersIgnorePerRequestIDs(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -534,6 +562,7 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) { c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) groupID := int64(4201) c.Set("api_key", &APIKey{ID: 501, GroupID: &groupID}) + SetOpenAIHTTPResponseOwner(c, 601, 501) svc := &OpenAIGatewayService{} account := &Account{ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} @@ -542,6 +571,22 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) { got, err := svc.getOpenAIWSStateStore().GetResponseAccount(context.Background(), groupID, "resp_http_001") require.NoError(t, err) require.Equal(t, account.ID, got) + + owned, err := svc.ValidateOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_http_001", 601, 501) + require.NoError(t, err) + require.True(t, owned) + + owned, err = svc.ValidateOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_http_001", 601, 502) + require.NoError(t, err) + require.True(t, owned, "API keys owned by the same downstream user remain interoperable") + + owned, err = svc.ValidateOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_http_001", 602, 501) + require.NoError(t, err) + require.False(t, owned) + + owned, err = svc.ValidateOpenAIHTTPResponseOwner(context.Background(), groupID, "resp_unknown", 601, 501) + require.NoError(t, err) + require.False(t, owned) } func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) { @@ -710,6 +755,13 @@ func (c *stubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) e return nil } +func (c *stubGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *stubGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) { now := time.Now() resetAt := now.Add(10 * time.Minute) @@ -1155,6 +1207,49 @@ func TestOpenAISelectAccountWithLoadAwareness_StickyWaitPlan(t *testing.T) { } } +func TestOpenAISelectAccountWithLoadAwareness_StickyCapacitySpilloverKeepsBinding(t *testing.T) { + sessionHash := "sticky-spillover" + groupID := int64(1) + repo := stubOpenAIAccountRepo{ + accounts: []Account{ + {ID: 1, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true, Concurrency: 6, Priority: 1, GroupIDs: []int64{groupID}}, + {ID: 2, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true, Concurrency: 6, Priority: 1, GroupIDs: []int64{groupID}}, + }, + } + cache := &stubGatewayCache{ + sessionBindings: map[string]int64{"openai:" + sessionHash: 1}, + } + concurrencyCache := stubConcurrencyCache{ + acquireResults: map[int64]bool{1: false, 2: true}, + waitCounts: map[int64]int{1: 1}, + loadMap: map[int64]*AccountLoadInfo{ + 1: {AccountID: 1, LoadRate: 100}, + 2: {AccountID: 2, LoadRate: 10}, + }, + } + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + cfg.Gateway.Scheduling.StickySessionMaxWaiting = 1 + + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, err := svc.SelectAccountWithLoadAwareness(context.Background(), &groupID, sessionHash, "gpt-4", nil) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(2), selection.Account.ID, "capacity spillover should use the other account for this request") + require.True(t, selection.Acquired) + require.Equal(t, int64(1), cache.sessionBindings["openai:"+sessionHash], "capacity spillover must not migrate the durable sticky binding") + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + func TestOpenAISelectAccountWithLoadAwareness_PrefersLowerLoad(t *testing.T) { groupID := int64(1) repo := stubOpenAIAccountRepo{ @@ -1759,7 +1854,7 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai require.Error(t, err) var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) - require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode) require.Contains(t, string(failoverErr.ResponseBody), "Please retry later") // 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。 // 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。 @@ -1831,8 +1926,8 @@ func TestOpenAIStreamingResponseFailedBeforeOutputRateLimitUsesPoolRetryPolicy(t require.Equal(t, http.StatusTooManyRequests, opsEvents[len(opsEvents)-1].UpstreamStatusCode) } -// 流内 rate limit 只产生 failover 错误,不写账号级限流/封禁状态: -// HTTP 200 流的 x-codex-* 头是正常配额快照,不能按 429 头驱动账号冷却。 +// 流内 rate limit 进入 OAuth 同账号重试窗口,但不立即写账号级限流/封禁状态: +// HTTP 200 流的 x-codex-* 头不能让窗口内的账号提前失去调度资格。 func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{ @@ -1876,7 +1971,8 @@ func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t * var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) - require.False(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RetryableOnSameAccount) + require.False(t, failoverErr.SameAccountRetryDeadline.IsZero()) require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } @@ -2288,7 +2384,7 @@ func TestOpenAIStreamingMissingTerminalEventReturnsIncompleteError(t *testing.T) go func() { defer func() { _ = pw.Close() }() - _, _ = pw.Write([]byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\"},\"output_index\":0}\n\n")) + _, _ = pw.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\",\"output_index\":0}\n\n")) }() _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model") @@ -2320,7 +2416,7 @@ func TestOpenAIStreamingPassthroughMissingTerminalEventReturnsIncompleteError(t go func() { defer func() { _ = pw.Close() }() - _, _ = pw.Write([]byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\"},\"output_index\":0}\n\n")) + _, _ = pw.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\",\"output_index\":0}\n\n")) }() _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "", "") @@ -2948,6 +3044,26 @@ func TestNormalizeOpenAICompactRequestBodyPreservesCurrentCodexPayloadFields(t * require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists()) } +func TestNormalizeOpenAICompactRequestBodyDropsParallelToolCallsWithoutUsableTools(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","input":[],"parallel_tool_calls":true}`, + `{"model":"gpt-5.5","input":[],"tools":[],"parallel_tool_calls":false}`, + `{"model":"gpt-5.5","input":[],"tools":null,"parallel_tool_calls":true}`, + `{"model":"gpt-5.5","input":[],"tools":{},"parallel_tool_calls":true}`, + } { + normalized, changed, err := normalizeOpenAICompactRequestBody([]byte(body)) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists(), string(normalized)) + } + + withTools := []byte(`{"model":"gpt-5.5","tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`) + normalized, _, err := normalizeOpenAICompactRequestBody(withTools) + require.NoError(t, err) + require.True(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Bool()) +} + func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -3382,10 +3498,10 @@ func TestParseSSEUsage_SelectiveParsing(t *testing.T) { svc := &OpenAIGatewayService{} usage := &OpenAIUsage{InputTokens: 9, OutputTokens: 8, CacheReadInputTokens: 7} - // 非 completed 事件,不应覆盖 usage + // 非终态事件中的显式 usage 作为兼容 fallback,非零字段会被合并。 svc.parseSSEUsage(`{"type":"response.in_progress","response":{"usage":{"input_tokens":1,"output_tokens":2}}}`, usage) - require.Equal(t, 9, usage.InputTokens) - require.Equal(t, 8, usage.OutputTokens) + require.Equal(t, 1, usage.InputTokens) + require.Equal(t, 2, usage.OutputTokens) require.Equal(t, 7, usage.CacheReadInputTokens) // completed 事件,应提取 usage @@ -3412,6 +3528,43 @@ func TestParseSSEUsage_SelectiveParsing(t *testing.T) { require.Equal(t, 6, usage.CacheReadInputTokens) } +func TestParseSSEUsage_NonTerminalUsageMergesNonZeroFields(t *testing.T) { + svc := &OpenAIGatewayService{} + usage := &OpenAIUsage{} + + svc.parseSSEUsage(`{"type":"response.in_progress","usage":{"input_tokens":17,"output_tokens":1,"input_tokens_details":{"cached_tokens":4}}}`, usage) + svc.parseSSEUsage(`{"type":"response.output_text.done","usage":{"input_tokens":0,"output_tokens":5,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":3}}}`, usage) + + require.Equal(t, 17, usage.InputTokens) + require.Equal(t, 5, usage.OutputTokens) + require.Equal(t, 4, usage.CacheReadInputTokens) + require.Equal(t, 3, usage.CacheCreationInputTokens) +} + +func TestParseSSEUsage_TerminalUsageReplacesFallback(t *testing.T) { + svc := &OpenAIGatewayService{} + usage := &OpenAIUsage{} + + svc.parseSSEUsage(`{"type":"response.output_text.done","usage":{"input_tokens":17,"output_tokens":5,"input_tokens_details":{"cached_tokens":4}}}`, usage) + svc.parseSSEUsage(`{"type":"response.completed","response":{"usage":{"input_tokens":19,"output_tokens":7}}}`, usage) + + require.Equal(t, 19, usage.InputTokens) + require.Equal(t, 7, usage.OutputTokens) + require.Zero(t, usage.CacheReadInputTokens) +} + +func TestParseSSEUsage_TerminalWithoutUsageKeepsFallback(t *testing.T) { + svc := &OpenAIGatewayService{} + usage := &OpenAIUsage{} + + svc.parseSSEUsage(`{"type":"response.in_progress","usage":{"input_tokens":17,"output_tokens":5}}`, usage) + svc.parseSSEUsage(`{"type":"response.completed","response":{"id":"resp_1"}}`, usage) + svc.parseSSEUsage(" [DONE]\n", usage) + + require.Equal(t, 17, usage.InputTokens) + require.Equal(t, 5, usage.OutputTokens) +} + func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *testing.T) { usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":5,"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":4}}}`)) require.True(t, ok) @@ -3442,6 +3595,30 @@ func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *te usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":20,"output_tokens":2,"cache_read_input_tokens":19,"input_tokens_details":{"cached_tokens":0}}}`)) require.True(t, ok) require.Zero(t, usage.CacheReadInputTokens, "官方嵌套缓存读取字段显式为零时仍应优先于兼容顶层别名") + + // xAI reports reasoning_tokens outside visible output_tokens. Only the + // arithmetic-consistent shape is independent; OpenAI's canonical shape + // already includes reasoning in completion/output_tokens. + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":10000,"output_tokens":500,"total_tokens":10800,"output_tokens_details":{"reasoning_tokens":300}}}`)) + require.True(t, ok) + require.Equal(t, 800, usage.OutputTokens) + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":10000,"output_tokens":500,"total_tokens":10500,"output_tokens_details":{"reasoning_tokens":300}}}`)) + require.True(t, ok) + require.Equal(t, 500, usage.OutputTokens) +} + +func TestExtractOpenAIUsageFromJSONBytes_IncludesGrokReasoningTokens(t *testing.T) { + usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"prompt_tokens":32,"completion_tokens":9,"total_tokens":135,"completion_tokens_details":{"reasoning_tokens":94}}}`)) + require.True(t, ok) + require.Equal(t, 103, usage.OutputTokens, "Grok Chat usage bills visible completion plus reasoning tokens") + + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":32,"output_tokens":103,"total_tokens":135,"output_tokens_details":{"reasoning_tokens":94}}}`)) + require.True(t, ok) + require.Equal(t, 103, usage.OutputTokens, "Responses output_tokens already includes reasoning when total confirms it") + + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":32,"output_tokens":9,"total_tokens":135,"output_tokens_details":{"reasoning_tokens":94}}}`)) + require.True(t, ok) + require.Equal(t, 103, usage.OutputTokens, "Responses detail-only shape is normalized when total exposes the full output") } func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) { @@ -3475,7 +3652,7 @@ func TestHandleSSEToJSON_CompletedEventReturnsJSON(t *testing.T) { `data: [DONE]`, }, "\n")) - usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") + usage, err := svc.handleSSEToJSON(resp, c, nil, body, "gpt-4o", "gpt-4o") require.NoError(t, err) require.NotNil(t, usage) require.Equal(t, 7, usage.InputTokens) @@ -3594,7 +3771,7 @@ func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T) `data: [DONE]`, }, "\n")) - usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-5.4", "gpt-5.4") + usage, err := svc.handleSSEToJSON(resp, c, nil, body, "gpt-5.4", "gpt-5.4") require.NoError(t, err) require.NotNil(t, usage) require.Equal(t, 4, usage.ImageOutputTokens) @@ -3621,7 +3798,7 @@ func TestHandleSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) { `data: [DONE]`, }, "\n")) - usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") + usage, err := svc.handleSSEToJSON(resp, c, nil, body, "gpt-4o", "gpt-4o") require.NoError(t, err) require.NotNil(t, usage) require.Equal(t, 0, usage.InputTokens) @@ -3645,7 +3822,7 @@ func TestHandleSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) { `data: [DONE]`, }, "\n")) - usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") + usage, err := svc.handleSSEToJSON(resp, c, nil, body, "gpt-4o", "gpt-4o") require.Nil(t, usage) require.Error(t, err) require.Equal(t, http.StatusBadGateway, rec.Code) diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index 00a62f3f3e..70c000e321 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -117,7 +117,7 @@ func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg strin } func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { - if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + if upstreamStatusCode < http.StatusBadRequest { return false } @@ -132,6 +132,15 @@ func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { return true } + if isOpenAICapacityShedMessage(upstreamMsg) || + isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "error.message").String()) || + isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "response.error.message").String()) || + (!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody))) { + return true + } + if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + return false + } if upstreamStatusCode != http.StatusBadRequest { return false } @@ -161,7 +170,27 @@ func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string if match(gjson.GetBytes(upstreamBody, "error.message").String()) { return true } - return match(string(upstreamBody)) + if match(gjson.GetBytes(upstreamBody, "response.error.message").String()) || + match(gjson.GetBytes(upstreamBody, "message").String()) { + return true + } + // A valid JSON error may echo arbitrary request content. Only its explicit + // error fields are authoritative; scan the whole body only for non-JSON + // providers that return a plain-text error response. + return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody)) +} + +func isOpenAICapacityShedMessage(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + return strings.Contains(lower, "server is overloaded") || + strings.Contains(lower, "servers are overloaded") || + strings.Contains(lower, "servers are currently overloaded") +} + +func isOpenAIRequestScopedCapacityShed(upstreamMsg string, upstreamBody []byte) bool { + return isOpenAIUpstreamCapacityShedEvent(upstreamBody) || + isOpenAICapacityShedMessage(upstreamMsg) || + (!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody))) } func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { @@ -206,7 +235,10 @@ func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { return true } } - return match(string(upstreamBody)) + // Do not let echoed request content in a structured JSON error change the + // retry/client-status classification. Plain-text upstream errors remain + // supported by scanning the whole body only when it is not valid JSON. + return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody)) } func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { @@ -219,9 +251,18 @@ func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool } func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { + // cyber_policy is request-scoped even when an intermediary wraps the + // provider response in a retryable 5xx status. Never punish or rotate the + // selected credential for it. + if hit, _, _ := detectOpenAICyberPolicy(upstreamBody); hit { + return false + } if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { return false } + if isOpenAIHTTPUpstreamAccessStateError(statusCode, upstreamMsg, upstreamBody) { + return true + } if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) { return true } @@ -248,29 +289,152 @@ func newOpenAIUpstreamFailoverError( upstreamMsg string, retryableOnSameAccount bool, ) *UpstreamFailoverError { + requestScopedCapacity := isOpenAIRequestScopedCapacityShed(upstreamMsg, responseBody) failoverErr := &UpstreamFailoverError{ StatusCode: statusCode, ResponseBody: responseBody, ResponseHeaders: responseHeaders.Clone(), - RetryableOnSameAccount: retryableOnSameAccount, + RetryableOnSameAccount: retryableOnSameAccount || requestScopedCapacity, + RequestScopedTransient: requestScopedCapacity, } if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, responseBody) { failoverErr.RetryableOnSameAccount = false + failoverErr.RequestScopedTransient = false failoverErr.Scope = GatewayFailureScopeAccount failoverErr.Reason = openAIRequestBodyTooLargeReason failoverErr.NextAccountAction = NextAccountRetry failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage } + if isOpenAIHTTPUpstreamAccessStateError(statusCode, upstreamMsg, responseBody) { + failoverErr.RetryableOnSameAccount = false + failoverErr.RequestScopedTransient = false + failoverErr.Stage = GatewayFailureStageAccountAuth + failoverErr.Scope = GatewayFailureScopeAccount + failoverErr.Reason = OpenAIUpstreamAccessStateReason + failoverErr.NextAccountAction = NextAccountRetry + failoverErr.ClientStatusCode = http.StatusBadGateway + failoverErr.ClientMessage = openAIUpstreamAccessUnavailableClientMessage + } else if requestScopedCapacity { + // Preserve the provider's actionable overload message after gateway + // retries are exhausted, but expose it as a retryable server_error. + failoverErr.ClientStatusCode = http.StatusServiceUnavailable + failoverErr.ClientMessage = openAICapacityShedClientMessage(upstreamMsg, responseBody) + } return failoverErr } +func (s *OpenAIGatewayService) newOpenAIAccountFailoverError( + account *Account, + statusCode int, + responseHeaders http.Header, + responseBody []byte, + upstreamMsg string, + shouldDisable bool, + retryableOnSameAccount bool, +) *UpstreamFailoverError { + return s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, responseHeaders, responseHeaders, responseBody, upstreamMsg, shouldDisable, retryableOnSameAccount) +} + +func (s *OpenAIGatewayService) newOpenAIAccountFailoverErrorWithClassificationHeaders( + account *Account, + statusCode int, + responseHeaders http.Header, + classificationHeaders http.Header, + responseBody []byte, + upstreamMsg string, + shouldDisable bool, + retryableOnSameAccount bool, +) *UpstreamFailoverError { + oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account, statusCode, shouldDisable, classificationHeaders, responseBody) + failoverErr := newOpenAIUpstreamFailoverError( + statusCode, + responseHeaders, + responseBody, + upstreamMsg, + retryableOnSameAccount || oauth429Retry, + ) + if oauth429Retry { + failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account) + failoverErr.SameAccountRetryDelay = openAIOAuth429SameAccountRetryDelay(responseHeaders, failoverErr.SameAccountRetryDeadline) + } + return failoverErr +} + +const ( + openAIUpstreamAccessUnavailableClientMessage = "Upstream access is temporarily unavailable, please retry later" + // OpenAIUpstreamAccessStateReason marks a provider credential whose + // account, workspace, or organization is unavailable. + OpenAIUpstreamAccessStateReason = GatewayFailureReason("openai_upstream_access_state") + // OpenAIHTTPContinuationUnsupportedReason identifies accounts that cannot + // preserve an official Responses HTTP continuation without dropping state. + OpenAIHTTPContinuationUnsupportedReason = GatewayFailureReason("openai_http_continuation_unsupported") +) + +// isOpenAIUpstreamAccessStateError recognizes provider-side credential state +// failures only from explicit structured codes. Free-form messages may contain +// echoed user input, including inside stream terminal error.message fields. +func isOpenAIUpstreamAccessStateError(_ string, body []byte) bool { + if len(body) == 0 || !gjson.ValidBytes(body) { + return false + } + for _, path := range []string{"error.code", "response.error.code", "detail.code", "code"} { + if isOpenAIUpstreamAccessStateCode(gjson.GetBytes(body, path).String()) { + return true + } + } + return false +} + +func isOpenAIUpstreamAccessStateCode(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "deactivated_workspace" { + return true + } + for _, subject := range []string{"workspace", "account", "organization", "org"} { + for _, state := range []string{"deactivated", "disabled", "suspended"} { + if value == subject+"_"+state || value == state+"_"+subject { + return true + } + } + } + return false +} + +// isOpenAIHTTPUpstreamAccessStateError is deliberately status-independent: +// known provider codes are durable evidence, while 401/403 messages without +// such a code must flow through the existing authentication/403 policies. +func isOpenAIHTTPUpstreamAccessStateError(_ int, _ string, body []byte) bool { + return isOpenAIUpstreamAccessStateError("", body) +} + +func openAICapacityShedClientMessage(upstreamMsg string, body []byte) string { + for _, candidate := range []string{ + upstreamMsg, + gjson.GetBytes(body, "error.message").String(), + gjson.GetBytes(body, "response.error.message").String(), + gjson.GetBytes(body, "message").String(), + } { + candidate = sanitizeUpstreamErrorMessage(strings.TrimSpace(candidate)) + if candidate != "" && isOpenAICapacityShedMessage(candidate) { + return candidate + } + } + return "Upstream service is temporarily overloaded, please retry later" +} + // IsOpenAIRequestBodyTooLarge reports whether another account may accept the // same request even though the selected account rejected its serialized size. func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool { return e != nil && e.Reason == openAIRequestBodyTooLargeReason } +// IsOpenAICapacityShed reports whether typed client fields were derived from a +// recognized provider overload rather than supplied by an unrelated failure. +func (e *UpstreamFailoverError) IsOpenAICapacityShed() bool { + return e != nil && e.RequestScopedTransient && isOpenAIRequestScopedCapacityShed("", e.ResponseBody) +} + func marshalOpenAIUpstreamJSON(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index c55bcdbeac..7489456bb8 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -146,6 +146,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if !isGrokVideoUsageResult(result, nil) { ApplyOpenAIImageBillingResolution(result) } + logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(result)) // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。 // 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。 @@ -177,7 +178,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的 // Resolve,以免污染 user:group 倍率缓存。 baseMultiplier := multiplier - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, openAIUsagePricingAt(input)) + pricingAt := openAIUsagePricingAt(input) + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt) videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier) var cost *CostBreakdown @@ -200,6 +202,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec result.UpstreamModel, result.Model, ) + billingModels = s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, billingModels) serviceTier := "" if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) @@ -224,6 +227,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec tokens, serviceTier, longContextBillingGate, + pricingAt, ) if err != nil { if !isUsagePricingUnavailableError(err) { @@ -253,10 +257,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { - responseModels := usageBillingModelCandidates(responseModel) + responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel)) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, ) // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 @@ -505,6 +509,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( tokens UsageTokens, serviceTier string, longContextBillingGate *bool, + pricingAt time.Time, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) if result != nil && result.WebSearchCalls > 0 { @@ -554,6 +559,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( apiKey, candidate, multiplier, + pricingAt, tokens, serviceTier, longContextBillingGate, @@ -585,7 +591,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( if tokenCost == nil { if tokenBillingAttempted { if lastErr == nil { - lastErr = errors.New("no non-empty billing model candidates") + lastErr = fmt.Errorf("%w: no non-empty billing model candidates", ErrModelPricingUnavailable) } return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) } @@ -593,8 +599,11 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( if searchCost != nil { return searchCost, nil } + // 空候选按「无价可循」处理并携带 ErrModelPricingUnavailable:上层据此走 + // 零成本+告警落账,而不是丢弃整条 usage 记录。CN 账号的 claude-* 候选被 + // filterCNProviderBillingModelCandidates 全数过滤后即落到这里。 if lastErr == nil { - lastErr = errors.New("openai usage billing model is empty") + lastErr = fmt.Errorf("%w: openai usage billing model is empty", ErrModelPricingUnavailable) } return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) } @@ -643,6 +652,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( apiKey *APIKey, billingModel string, multiplier float64, + pricingAt time.Time, tokens UsageTokens, serviceTier string, longContextBillingGate *bool, @@ -651,7 +661,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( gid := apiKey.Group.ID return s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, ServiceTier: serviceTier, Resolver: s.resolver, LongContextBillingEnabled: longContextBillingGate, }) @@ -841,6 +851,36 @@ func groupMediaPricingLooksIncomplete(group *Group) bool { group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil } +// filterCNProviderBillingModelCandidates 过滤国产供应商(kimi/zhipu/deepseek) +// 账号的计费候选模型名:claude-* 候选仅在运营者显式配置了分组/渠道定价时保留。 +// +// 背景:候选链的兜底候选含客户端请求的原始模型名。CN 上游的 Anthropic 兼容端点 +// 接受 claude-* 模型名但从不真正服务 Claude 模型;若放行,目录里的 Claude 价卡 +// 与 getFallbackPricing 的 "claude"→Sonnet 统一兜底会把 CN 流量按 Claude 原价 +// (数倍~数十倍)静默误计,且 usage 日志显示的正是 claude-* 名,无从察觉。 +// 候选全部落空时走既有的零成本+告警路径(openai_usage.pricing_missing_record_ +// zero_cost),与定价层「未知型号不回退以避免误计价」的既有设计意图一致; +// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或 +// 分组/渠道显式定价。 +func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string { + if account == nil || !account.IsCNProvider() { + return candidates + } + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + continue + } + if strings.Contains(strings.ToLower(trimmed), "claude") && + s.resolveOpenAIChannelPricing(ctx, trimmed, apiKey) == nil { + continue + } + out = append(out, candidate) + } + return out +} + func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { if s.resolver == nil || apiKey == nil || apiKey.Group == nil { return nil @@ -1035,7 +1075,9 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc go func() { updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { + notifyOpenAIAutoReset(accountID) + } }() } diff --git a/backend/internal/service/openai_gpt56_max_test.go b/backend/internal/service/openai_gpt56_max_test.go index cbca2ff3ee..9dc865295c 100644 --- a/backend/internal/service/openai_gpt56_max_test.go +++ b/backend/internal/service/openai_gpt56_max_test.go @@ -24,7 +24,8 @@ func TestNormalizeOpenAIReasoningEffortForGPT56(t *testing.T) { {name: "Sol 保留 max", raw: "max", model: "gpt-5.6-sol", want: "max"}, {name: "Terra 保留 max", raw: "max", model: "openai/gpt-5.6-terra", want: "max"}, {name: "Luna 后缀保留 max", raw: "max", model: "gpt-5.6-luna-2026-07-09", want: "max"}, - {name: "其他模型沿用 xhigh", raw: "max", model: "deepseek-v4-pro", want: "xhigh"}, + {name: "DeepSeek V4 保留 max", raw: "max", model: "deepseek-v4-pro", want: "max"}, + {name: "旧 GPT 模型沿用 xhigh", raw: "max", model: "gpt-5.5", want: "xhigh"}, } for _, tt := range tests { diff --git a/backend/internal/service/openai_guardian_affinity.go b/backend/internal/service/openai_guardian_affinity.go new file mode 100644 index 0000000000..30ed388f0c --- /dev/null +++ b/backend/internal/service/openai_guardian_affinity.go @@ -0,0 +1,133 @@ +package service + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +const ( + codexAutoReviewModel = "codex-auto-review" + openAISubagentHeader = "x-openai-subagent" + codexParentThreadIDHeader = "x-codex-parent-thread-id" + codexTurnMetadataHeader = "x-codex-turn-metadata" +) + +type openAIGuardianParentAffinityContextKey struct{} + +type openAIGuardianParentAffinity struct { + currentSessionHash string + legacySessionHash string +} + +// WithOpenAIGuardianParentAffinity records a Codex review request's parent +// thread as a routing hint. The hint is resolved against the current group's +// sticky-session namespace later; client headers never carry an account ID. +func WithOpenAIGuardianParentAffinity(ctx context.Context, c *gin.Context, body []byte, requestedModel string) context.Context { + if ctx == nil || c == nil || !strings.EqualFold(strings.TrimSpace(requestedModel), codexAutoReviewModel) { + return ctx + } + + headerMetadata := c.GetHeader(codexTurnMetadataHeader) + bodyMetadata := openAIRequestPayloadView(body).Get("client_metadata.x-codex-turn-metadata").String() + if !hasUnambiguousOpenAICodexReviewSubagent( + c.GetHeader(openAISubagentHeader), + codexSubagentKindFromMetadata(headerMetadata), + codexSubagentKindFromMetadata(bodyMetadata), + ) { + return ctx + } + + parentID := "" + for _, candidate := range []string{ + strings.TrimSpace(c.GetHeader(codexParentThreadIDHeader)), + codexParentThreadIDFromMetadata(headerMetadata), + codexParentThreadIDFromMetadata(bodyMetadata), + } { + if candidate == "" { + continue + } + if parentID != "" && parentID != candidate { + return ctx + } + parentID = candidate + } + if parentID == "" { + return ctx + } + + currentHash, legacyHash := deriveOpenAISessionHashes(parentID) + if currentHash == "" { + return ctx + } + return context.WithValue(ctx, openAIGuardianParentAffinityContextKey{}, openAIGuardianParentAffinity{ + currentSessionHash: currentHash, + legacySessionHash: legacyHash, + }) +} + +func codexParentThreadIDFromMetadata(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || !gjson.Valid(raw) { + return "" + } + return strings.TrimSpace(gjson.Get(raw, "parent_thread_id").String()) +} + +func codexSubagentKindFromMetadata(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || !gjson.Valid(raw) { + return "" + } + return strings.TrimSpace(gjson.Get(raw, "subagent_kind").String()) +} + +func hasUnambiguousOpenAICodexReviewSubagent(candidates ...string) bool { + subagent := "" + for _, candidate := range candidates { + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" { + continue + } + if subagent != "" && subagent != candidate { + return false + } + subagent = candidate + } + return subagent == "guardian" || subagent == "review" +} + +func openAIGuardianParentAffinityFromContext(ctx context.Context) (openAIGuardianParentAffinity, bool) { + if ctx == nil { + return openAIGuardianParentAffinity{}, false + } + affinity, ok := ctx.Value(openAIGuardianParentAffinityContextKey{}).(openAIGuardianParentAffinity) + return affinity, ok && affinity.currentSessionHash != "" +} + +func preserveOpenAIGuardianParentBinding(ctx context.Context, sessionHash string) bool { + affinity, ok := openAIGuardianParentAffinityFromContext(ctx) + if !ok { + return false + } + sessionHash = strings.TrimSpace(sessionHash) + return sessionHash != "" && (sessionHash == affinity.currentSessionHash || sessionHash == affinity.legacySessionHash) +} + +func (s *OpenAIGatewayService) resolveOpenAIGuardianParentAccountID(ctx context.Context, groupID *int64) int64 { + if s == nil || s.cache == nil { + return 0 + } + affinity, ok := openAIGuardianParentAffinityFromContext(ctx) + if !ok { + return 0 + } + lookupCtx := withOpenAILegacySessionHash(ctx, affinity.legacySessionHash) + accountID, err := s.getStickySessionAccountID(lookupCtx, groupID, affinity.currentSessionHash) + if err != nil || accountID <= 0 { + return 0 + } + return accountID +} diff --git a/backend/internal/service/openai_guardian_affinity_test.go b/backend/internal/service/openai_guardian_affinity_test.go new file mode 100644 index 0000000000..4901b36647 --- /dev/null +++ b/backend/internal/service/openai_guardian_affinity_test.go @@ -0,0 +1,450 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type guardianAffinityGroupRepo struct { + GroupRepository + group *Group + err error +} + +type guardianAffinityAccountRepo struct { + schedulerGroupAwareOpenAIAccountRepo + setErrorCalls int +} + +func (r *guardianAffinityAccountRepo) SetError(context.Context, int64, string) error { + r.setErrorCalls++ + return nil +} + +func (r guardianAffinityGroupRepo) GetByID(context.Context, int64) (*Group, error) { + if r.err != nil { + return nil, r.err + } + return r.group, nil +} + +func (r guardianAffinityGroupRepo) GetByIDLite(context.Context, int64) (*Group, error) { + if r.err != nil { + return nil, r.err + } + return r.group, nil +} + +func guardianAffinityTestContext(t *testing.T, model, subagent, parentHeader, metadata string) context.Context { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set(openAISubagentHeader, subagent) + if parentHeader != "" { + c.Request.Header.Set(codexParentThreadIDHeader, parentHeader) + } + if metadata != "" { + c.Request.Header.Set(codexTurnMetadataHeader, metadata) + } + return WithOpenAIGuardianParentAffinity(context.Background(), c, nil, model) +} + +func TestWithOpenAIGuardianParentAffinity_RequiresUnambiguousReviewLineage(t *testing.T) { + parentID := "11111111-1111-4111-8111-111111111111" + wantHash := DeriveSessionHashFromSeed(parentID) + + for _, subagent := range []string{"guardian", "review", "GUARDIAN"} { + t.Run(subagent, func(t *testing.T) { + ctx := guardianAffinityTestContext(t, codexAutoReviewModel, subagent, parentID, `{"parent_thread_id":"`+parentID+`"}`) + affinity, ok := openAIGuardianParentAffinityFromContext(ctx) + require.True(t, ok) + require.Equal(t, wantHash, affinity.currentSessionHash) + }) + } + + t.Run("metadata only", func(t *testing.T) { + ctx := guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", "", `{"parent_thread_id":"`+parentID+`"}`) + _, ok := openAIGuardianParentAffinityFromContext(ctx) + require.True(t, ok) + }) + + t.Run("websocket envelope metadata", func(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/openai/v1/responses", nil) + body := []byte(`{"type":"response.create","response":{"model":"codex-auto-review","client_metadata":{"x-codex-turn-metadata":"{\"parent_thread_id\":\"` + parentID + `\",\"subagent_kind\":\"guardian\"}"}}}`) + ctx := WithOpenAIGuardianParentAffinity(context.Background(), c, body, codexAutoReviewModel) + affinity, ok := openAIGuardianParentAffinityFromContext(ctx) + require.True(t, ok) + require.Equal(t, wantHash, affinity.currentSessionHash) + }) + + for name, ctx := range map[string]context.Context{ + "ordinary model": guardianAffinityTestContext(t, "gpt-5.6-sol", "guardian", parentID, ""), + "ordinary subagent": guardianAffinityTestContext(t, codexAutoReviewModel, "collab_spawn", parentID, ""), + "missing parent": guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", "", ""), + "conflicting lineage": guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", parentID, `{"parent_thread_id":"different-parent"}`), + "conflicting subagent": guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", parentID, `{"parent_thread_id":"`+parentID+`","subagent_kind":"collab_spawn"}`), + } { + t.Run(name, func(t *testing.T) { + _, ok := openAIGuardianParentAffinityFromContext(ctx) + require.False(t, ok) + }) + } +} + +func TestOpenAIGatewayService_GuardianParentAffinitySelectsParentAccountAcrossSchedulers(t *testing.T) { + parentID := "22222222-2222-4222-8222-222222222222" + parentHash := DeriveSessionHashFromSeed(parentID) + groupID := int64(102001) + + for _, mode := range []struct { + name string + advanced string + stickyWeighted string + }{ + {name: "legacy", advanced: "false"}, + {name: "advanced", advanced: "true"}, + {name: "advanced sticky weighted", advanced: "true", stickyWeighted: "true"}, + } { + t.Run(mode.name, func(t *testing.T) { + accounts := []Account{ + { + ID: 39001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, Credentials: map[string]any{"plan_type": "team"}, + }, + { + ID: 39002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, Credentials: map[string]any{"plan_type": "team"}, + }, + } + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 2 + cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:" + parentHash: 39001}} + svc := &OpenAIGatewayService{ + accountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}, + cache: cache, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService(mode.advanced, mode.stickyWeighted), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{acquireResults: map[int64]bool{39001: true, 39002: true}}), + } + + ctx := guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", parentID, "") + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "guardian-child-session", codexAutoReviewModel, + nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, int64(39001), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerGuardianParent, decision.Layer) + require.Zero(t, cache.deletedSessions["openai:"+parentHash]) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_GuardianParentAffinityFallsBackWithoutCrossGroupOrFailoverBypass(t *testing.T) { + parentID := "33333333-3333-4333-8333-333333333333" + parentHash := DeriveSessionHashFromSeed(parentID) + groupID := int64(102011) + otherGroupID := int64(102012) + + for name, excluded := range map[string]map[int64]struct{}{ + "parent moved out of group": nil, + "parent excluded after upstream failure": {39011: {}}, + } { + t.Run(name, func(t *testing.T) { + parentGroups := []int64{groupID} + if excluded == nil { + parentGroups = []int64{otherGroupID} + } + accounts := []Account{ + {ID: 39011, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, GroupIDs: parentGroups, Credentials: map[string]any{"plan_type": "team"}}, + {ID: 39012, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, GroupIDs: []int64{groupID}, Credentials: map[string]any{"plan_type": "team"}}, + } + cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:" + parentHash: 39011}} + svc := &OpenAIGatewayService{ + accountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}, + cache: cache, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{acquireResults: map[int64]bool{39011: true, 39012: true}}), + } + + ctx := guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", parentID, "") + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "guardian-fallback-child", codexAutoReviewModel, + excluded, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, int64(39012), selection.Account.ID) + require.Zero(t, cache.deletedSessions["openai:"+parentHash], "a child request must never delete its parent's binding") + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_GuardianParentHashCollisionPreservesParentBinding(t *testing.T) { + parentID := "44444444-4444-4444-8444-444444444444" + parentHash := DeriveSessionHashFromSeed(parentID) + groupID := int64(102021) + otherGroupID := int64(102022) + + for _, advanced := range []string{"false", "true"} { + t.Run(map[string]string{"false": "legacy", "true": "advanced"}[advanced], func(t *testing.T) { + accounts := []Account{ + {ID: 39021, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, GroupIDs: []int64{otherGroupID}, Credentials: map[string]any{"plan_type": "team"}}, + {ID: 39022, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, GroupIDs: []int64{groupID}, Credentials: map[string]any{"plan_type": "team"}}, + } + cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:" + parentHash: 39021}} + svc := &OpenAIGatewayService{ + accountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}, + cache: cache, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService(advanced), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{acquireResults: map[int64]bool{39021: true, 39022: true}}), + } + + ctx := guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", parentID, "") + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", parentHash, codexAutoReviewModel, + nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, int64(39022), selection.Account.ID) + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(ctx, &groupID, parentHash, selection.Account.ID)) + require.Equal(t, int64(39021), cache.sessionBindings["openai:"+parentHash]) + require.Zero(t, cache.deletedSessions["openai:"+parentHash]) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_GuardianParentAffinityHonorsRequiredPrivacy(t *testing.T) { + parentID := "55555555-5555-4555-8555-555555555555" + parentHash := DeriveSessionHashFromSeed(parentID) + groupID := int64(102031) + + for _, advanced := range []string{"false", "true"} { + t.Run(map[string]string{"false": "legacy", "true": "advanced"}[advanced], func(t *testing.T) { + accounts := []Account{ + {ID: 39031, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, GroupIDs: []int64{groupID}, Credentials: map[string]any{"plan_type": "team"}}, + {ID: 39032, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, GroupIDs: []int64{groupID}, Credentials: map[string]any{"plan_type": "team"}, Extra: map[string]any{"privacy_mode": PrivacyModeTrainingOff}}, + } + repo := &guardianAffinityAccountRepo{schedulerGroupAwareOpenAIAccountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}} + cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{"openai:" + parentHash: 39031}} + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: cache, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService(advanced), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{acquireResults: map[int64]bool{39031: true, 39032: true}}), + schedulerSnapshot: &SchedulerSnapshotService{ + accountRepo: repo, + groupRepo: guardianAffinityGroupRepo{group: &Group{ID: groupID, Name: "privacy", RequirePrivacySet: true}}, + }, + } + + ctx := guardianAffinityTestContext(t, codexAutoReviewModel, "guardian", parentID, "") + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "guardian-privacy-child", codexAutoReviewModel, + nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, int64(39032), selection.Account.ID) + require.Zero(t, repo.setErrorCalls, "a group-scoped privacy gate must not globally error a shared account") + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(&accounts[0], codexAutoReviewModel)) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_PreviousResponseHonorsGroupAndRequiredPrivacy(t *testing.T) { + groupID := int64(3904) + + tests := []struct { + name string + boundAccount Account + groupErr error + }{ + { + name: "privacy unset", + boundAccount: Account{ + ID: 39041, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_enabled": true}, + }, + }, + { + name: "privacy policy lookup error fails closed", + boundAccount: Account{ + ID: 39041, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_enabled": true}, + }, + groupErr: errors.New("group repository unavailable"), + }, + { + name: "different group", + boundAccount: Account{ + ID: 39041, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, + GroupIDs: []int64{groupID + 1}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + "privacy_mode": PrivacyModeTrainingOff, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fallback := Account{ + ID: 39042, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + "privacy_mode": PrivacyModeTrainingOff, + }, + } + accounts := []Account{tc.boundAccount, fallback} + repo := &guardianAffinityAccountRepo{schedulerGroupAwareOpenAIAccountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}} + cache := &schedulerTestGatewayCache{} + store := NewOpenAIWSStateStore(cache) + groupRepo := guardianAffinityGroupRepo{ + group: &Group{ + ID: groupID, Name: "privacy-required", Platform: PlatformOpenAI, + Status: StatusActive, RequirePrivacySet: true, + }, + err: tc.groupErr, + } + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: cache, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(&schedulerTestConcurrencyCache{}), + openaiWSStateStore: store, + schedulerSnapshot: &SchedulerSnapshotService{ + accountRepo: repo, + groupRepo: groupRepo, + }, + } + responseID := "resp_privacy_guard" + require.NoError(t, store.BindResponseAccount(context.Background(), groupID, responseID, tc.boundAccount.ID, time.Hour)) + + directSelection, directErr := svc.SelectAccountByPreviousResponseID( + context.Background(), &groupID, responseID, codexAutoReviewModel, nil, false, + ) + require.NoError(t, directErr) + require.Nil(t, directSelection, "the previous-response helper must enforce fresh group/privacy state") + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + context.Background(), &groupID, responseID, "", codexAutoReviewModel, + nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, + false, false, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, fallback.ID, selection.Account.ID) + require.NotEqual(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) + require.Zero(t, repo.setErrorCalls) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(&accounts[0], codexAutoReviewModel)) + boundAccountID, getErr := store.GetResponseAccount(context.Background(), groupID, responseID) + require.NoError(t, getErr) + require.Equal(t, tc.boundAccount.ID, boundAccountID, "transient policy misses must preserve the response binding") + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_PreviousResponseSimpleModeIgnoresGroupMembership(t *testing.T) { + groupID := int64(3905) + bound := Account{ + ID: 39051, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, + GroupIDs: []int64{groupID + 1}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_enabled": true}, + } + fallback := Account{ + ID: 39052, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"openai_apikey_responses_websockets_v2_enabled": true}, + } + accounts := []Account{bound, fallback} + repo := &guardianAffinityAccountRepo{schedulerGroupAwareOpenAIAccountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}} + cache := &schedulerTestGatewayCache{} + store := NewOpenAIWSStateStore(cache) + cfg := &config.Config{RunMode: config.RunModeSimple} + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(&schedulerTestConcurrencyCache{}), + openaiWSStateStore: store, + schedulerSnapshot: &SchedulerSnapshotService{ + accountRepo: repo, + groupRepo: guardianAffinityGroupRepo{group: &Group{ + ID: groupID, Name: "simple-mode", Platform: PlatformOpenAI, Status: StatusActive, + }}, + }, + } + responseID := "resp_simple_mode_cross_group" + require.NoError(t, store.BindResponseAccount(context.Background(), groupID, responseID, bound.ID, time.Hour)) + + directSelection, err := svc.SelectAccountByPreviousResponseID( + context.Background(), &groupID, responseID, codexAutoReviewModel, nil, false, + ) + require.NoError(t, err) + require.NotNil(t, directSelection) + require.Equal(t, bound.ID, directSelection.Account.ID) + if directSelection.ReleaseFunc != nil { + directSelection.ReleaseFunc() + } + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + context.Background(), &groupID, responseID, "", codexAutoReviewModel, + nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, + false, false, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, bound.ID, selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index edd3fc92fc..c661aade36 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -34,12 +34,13 @@ const ( openAIImagesGenerationsURL = "https://api.openai.com/v1/images/generations" openAIImagesEditsURL = "https://api.openai.com/v1/images/edits" - openAIChatGPTStartURL = "https://chatgpt.com/" - openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files" - openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download - openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part - openAIImagesResponsesMainModel = "gpt-5.4-mini" + openAIChatGPTStartURL = "https://chatgpt.com/" + openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files" + openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download + openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part + openAIImagesResponsesMainModel = "gpt-5.4-mini" + openAIImagesVerbatimPromptInstructions = "When invoking the image_generation tool, use the user's image prompt verbatim. Do not rewrite, expand, summarize, embellish, translate, normalize punctuation, or add or remove visual details or constraints. Preserve the original language, wording, capitalization, quotes, and punctuation exactly." ) type OpenAIImagesCapability string @@ -562,7 +563,7 @@ func (s *OpenAIGatewayService) ForwardImages( switch account.Type { case AccountTypeAPIKey: return s.forwardOpenAIImagesAPIKey(ctx, c, account, body, parsed, channelMappedModel) - case AccountTypeOAuth: + case AccountTypeOAuth, AccountTypeSetupToken: return s.forwardOpenAIImagesOAuth(ctx, c, account, parsed, channelMappedModel) default: return nil, fmt.Errorf("unsupported account type: %s", account.Type) @@ -589,6 +590,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if err := validateOpenAIImagesModel(upstreamModel); err != nil { return nil, err } + SetOpsUpstreamModel(c, upstreamModel) logger.LegacyPrintf( "service.openai_gateway", "[OpenAI] Images request routing request_model=%s upstream_model=%s endpoint=%s account_type=%s", @@ -623,7 +625,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( proxyURL = account.Proxy.URL() } upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) @@ -658,11 +660,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( Message: upstreamMsg, }) shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, upstreamModel) - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode) + if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests { + return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMsg, shouldDisable, retryableOnSameAccount) } + if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMsg, respBody) { + return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, retryableOnSameAccount) + } + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount} } return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel) } diff --git a/backend/internal/service/openai_images_incomplete_test.go b/backend/internal/service/openai_images_incomplete_test.go index 6a8ba56225..ebd3c0e527 100644 --- a/backend/internal/service/openai_images_incomplete_test.go +++ b/backend/internal/service/openai_images_incomplete_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gin-gonic/gin" ) @@ -161,6 +162,91 @@ func TestImagesOAuthNonStreaming_ContentRefusalReturns400NoRetry(t *testing.T) { } } +func TestImagesOAuthNonStreaming_TextFallbackReturnsCapabilityError(t *testing.T) { + upstreamSSE := "event: response.created\n" + + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\",\"status\":\"in_progress\",\"model\":\"gpt-5.4-mini\",\"output\":[]}}\n\n" + + "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Here's a polished image prompt for your request.\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Here's a polished image prompt for your request.\"}]}],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(upstreamSSE))} + + svc := &OpenAIGatewayService{} + _, _, _, err := svc.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, "b64_json", "gpt-image-2") + + var imgErr *OpenAIImagesUpstreamError + if !errors.As(err, &imgErr) { + t.Fatalf("expected *OpenAIImagesUpstreamError, got %T: %v", err, err) + } + if imgErr.StatusCode != http.StatusBadGateway { + t.Fatalf("text fallback should be retryable 502, got %d", imgErr.StatusCode) + } + if imgErr.Code != "image_generation_unavailable" { + t.Fatalf("text fallback should identify missing image execution, got %q", imgErr.Code) + } +} + +func TestImagesOAuthStreaming_TextFallbackReturnsCapabilityError(t *testing.T) { + upstreamSSE := "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Here's a polished image prompt for your request.\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[]}}\n\n" + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(upstreamSSE))} + + svc := &OpenAIGatewayService{} + _, _, _, _, err := svc.handleOpenAIImagesOAuthStreamingResponse(resp, c, time.Now(), "b64_json", "image_generation", "gpt-image-2") + + var imgErr *OpenAIImagesUpstreamError + if !errors.As(err, &imgErr) { + t.Fatalf("expected *OpenAIImagesUpstreamError, got %T: %v", err, err) + } + if imgErr.StatusCode != http.StatusBadGateway { + t.Fatalf("streaming text fallback should be retryable 502, got %d", imgErr.StatusCode) + } + if imgErr.Code != "image_generation_unavailable" { + t.Fatalf("streaming text fallback should identify missing image execution, got %q", imgErr.Code) + } + if strings.Contains(rec.Body.String(), "event: error") { + t.Fatal("retryable text fallback must remain unflushed for failover") + } +} + +func TestImagesOAuthStreaming_SplitSafetyRefusalReturns400(t *testing.T) { + upstreamSSE := "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"安全系\"}\n\n" + + "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"统拒绝生成\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"output\":[]}}\n\n" + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(upstreamSSE))} + + svc := &OpenAIGatewayService{} + _, _, _, _, err := svc.handleOpenAIImagesOAuthStreamingResponse(resp, c, time.Now(), "b64_json", "image_generation", "gpt-image-2") + + var imgErr *OpenAIImagesUpstreamError + if !errors.As(err, &imgErr) { + t.Fatalf("expected *OpenAIImagesUpstreamError, got %T: %v", err, err) + } + if imgErr.StatusCode != http.StatusBadRequest || imgErr.Code != "content_policy_violation" { + t.Fatalf("split safety refusal should remain a content-policy 400, got status=%d code=%q", imgErr.StatusCode, imgErr.Code) + } + if !strings.Contains(rec.Body.String(), "event: error") { + t.Fatal("content-policy refusal must reach the streaming client") + } +} + // extractOpenAIImagesModelRefusal:真空响应(无文字)返回空串。 func TestExtractModelRefusal_EmptyWhenNoText(t *testing.T) { body := "data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 4656d22c4a..9374a6f7c7 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -356,6 +356,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel) + req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions) input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) input, _ = sjson.SetBytes(input, "0.content.0.text", prompt) @@ -609,13 +610,10 @@ func openAIImagesUpstreamErrorFromSSEPayload(payload []byte) *OpenAIImagesUpstre } } -// extractOpenAIImagesModelRefusal 从上游 SSE 响应体提取「模型未出图、改用文字拒绝」 -// 的拒绝文本(内容审核场景)。 -// -// 上游 response.completed 无图时,模型常以 output_text / message 形式输出拒绝说明 -// (如“被安全系统判定为不适合生成”)。这类失败是内容策略拦截,重试/换账号均无效, -// 应把该文本作为内容策略错误透传给客户端。返回空串表示无文字输出(真空响应)。 -func extractOpenAIImagesModelRefusal(body []byte) string { +// extractOpenAIImagesModelText collects textual terminal output from an image +// request. A text response is evidence that the image tool did not produce an +// image; its semantics still need classification before choosing a client error. +func extractOpenAIImagesModelText(body []byte) string { var b strings.Builder collect := func(s string) { if s = strings.TrimSpace(s); s != "" { @@ -625,16 +623,14 @@ func extractOpenAIImagesModelRefusal(body []byte) string { _, _ = b.WriteString(s) } } - forEachOpenAISSEDataPayload(string(body), func(payload []byte) { + consumePayload := func(payload []byte) { if !gjson.ValidBytes(payload) { return } switch gjson.GetBytes(payload, "type").String() { case "response.output_text.delta": - // 流式文本增量。 collect(gjson.GetBytes(payload, "delta").String()) case "response.completed", "response.output_item.done": - // 终态里的 message/output_text。 gjson.GetBytes(payload, "response.output").ForEach(func(_, item gjson.Result) bool { if item.Get("type").String() == "message" { item.Get("content").ForEach(func(_, part gjson.Result) bool { @@ -655,14 +651,67 @@ func extractOpenAIImagesModelRefusal(body []byte) string { }) } } - }) - refusal := strings.TrimSpace(b.String()) - // 截断过长文本,避免把整段模型输出塞进错误响应。 - const maxRefusal = 600 - if len(refusal) > maxRefusal { - refusal = refusal[:maxRefusal] } - return refusal + if gjson.ValidBytes(body) { + consumePayload(body) + } else { + forEachOpenAISSEDataPayload(string(body), consumePayload) + } + text := strings.TrimSpace(b.String()) + const maxText = 600 + if len(text) > maxText { + return text[:maxText] + } + return text +} + +func isOpenAIImagesContentPolicyRefusal(text string) bool { + lower := strings.ToLower(text) + for _, marker := range []string{ + "content policy", "content_policy", "content filter", "content_filter", + "safety system", "safety policy", "safety violation", "moderation", + "安全系统", "安全策略", "安全政策", "内容政策", "内容审核", "违规内容", "不适合生成", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// extractOpenAIImagesModelRefusal returns only text with an explicit safety or +// moderation signal. Plain prompt suggestions are capability failures instead. +func extractOpenAIImagesModelRefusal(body []byte) string { + text := extractOpenAIImagesModelText(body) + if !isOpenAIImagesContentPolicyRefusal(text) { + return "" + } + return text +} + +func openAIImagesTextFallbackError(body []byte) *OpenAIImagesUpstreamError { + return openAIImagesTextFallbackErrorForText(extractOpenAIImagesModelText(body)) +} + +func openAIImagesTextFallbackErrorForText(text string) *OpenAIImagesUpstreamError { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + if isOpenAIImagesContentPolicyRefusal(text) { + return &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadRequest, + ErrorType: "image_generation_user_error", + Code: "content_policy_violation", + Message: sanitizeUpstreamErrorMessage(text), + } + } + return &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadGateway, + ErrorType: "upstream_error", + Code: "image_generation_unavailable", + Message: "Upstream did not execute image generation", + } } // summarizeOpenAIImagesNoOutputBody 从上游 SSE 响应体提取诊断摘要,用于软失败时 @@ -902,8 +951,18 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse( modelForCooldown = strings.TrimSpace(requestedModel[0]) } shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, modelForCooldown) + failoverErr := s.newOpenAIAccountFailoverError( + account, + resp.StatusCode, + resp.Header, + body, + upstreamMsg, + shouldDisable, + false, + ) + shouldFailover := shouldDisable || (account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests && failoverErr.RetryableOnSameAccount) kind := "http_error" - if shouldDisable { + if shouldFailover { kind = "failover" } appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ @@ -916,12 +975,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse( Message: upstreamMsg, Detail: upstreamDetail, }) - if shouldDisable { - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: body, - RetryableOnSameAccount: false, - } + if shouldFailover { + return nil, failoverErr } // Surface the real upstream error to the client. @@ -1261,26 +1316,14 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( } return OpenAIUsage{}, 0, nil, upstreamErr } - // 软失败兜底:上游无图。先区分两种情形(实测真因,见下): - // - // (A) 内容审核拒绝:模型未出图,但输出了文字拒绝(response.completed 里带 - // output_text / message,内容如“被安全系统判定为不适合生成”)。这是用户 - // prompt 触发 OpenAI 内容策略,模型主动拒绝改用文字回应。**换账号/重试均无效** - // (内容层拦截,与账号/承载模型无关),应把拒绝理由作为 400 透传给客户端, - // 避免无谓地重试 + 消耗其它账号配额,且让客户端拿到可读的拒绝原因。 - // (B) 真空响应:既无图也无任何文字输出(罕见,如偶发路由到 gpt-5.x-mini、 - // image_gen 工具未执行)。这是上游的概率性失败,此时才按可重试处理。 - if refusal := extractOpenAIImagesModelRefusal(body); refusal != "" { - refusalErr := &OpenAIImagesUpstreamError{ - StatusCode: http.StatusBadRequest, - ErrorType: "image_generation_user_error", - Code: "content_policy_violation", - Message: sanitizeUpstreamErrorMessage(refusal), + if textFallbackErr := openAIImagesTextFallbackError(body); textFallbackErr != nil { + setOpsUpstreamError(c, textFallbackErr.clientStatusCode(), textFallbackErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(body)) + if !IsOpenAIImagesRetryableUpstreamError(textFallbackErr) { + writeOpenAIImagesUpstreamErrorResponse(c, textFallbackErr) } - setOpsUpstreamError(c, http.StatusBadRequest, refusalErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(body)) - writeOpenAIImagesUpstreamErrorResponse(c, refusalErr) - return OpenAIUsage{}, 0, nil, refusalErr + return OpenAIUsage{}, 0, nil, textFallbackErr } + // 真空响应:既无图也无文字输出。它保持短暂可重试语义,优先同账号重试。 // (B) 真空响应:记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于 // 排查,并返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图),用尽后 @@ -1338,6 +1381,17 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( pendingResults := make([]openAIResponsesImageResult, 0, 1) pendingSeen := make(map[string]struct{}) streamMeta := openAIResponsesImageResult{Model: strings.TrimSpace(fallbackModel)} + var fallbackText strings.Builder + appendFallbackText := func(text string) { + if text == "" || fallbackText.Len() >= 600 { + return + } + remaining := 600 - fallbackText.Len() + if len(text) > remaining { + text = text[:remaining] + } + _, _ = fallbackText.WriteString(text) + } var createdAt int64 clientDisconnected := false lastDownstreamWriteAt := time.Now() @@ -1364,6 +1418,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( createdAt = eventCreatedAt } } + if gjson.GetBytes(dataBytes, "type").String() == "response.output_text.delta" { + appendFallbackText(gjson.GetBytes(dataBytes, "delta").String()) + } switch gjson.GetBytes(dataBytes, "type").String() { case "response.image_generation_call.partial_image": b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String()) @@ -1428,9 +1485,21 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( } reconcileOpenAIResponsesImageResultSizes(finalResults, nil) if len(finalResults) == 0 { + textFallbackErr := openAIImagesTextFallbackErrorForText(fallbackText.String()) + if textFallbackErr == nil { + textFallbackErr = openAIImagesTextFallbackError(dataBytes) + } + if textFallbackErr != nil { + retryable := IsOpenAIImagesRetryableUpstreamError(textFallbackErr) + setOpsUpstreamError(c, textFallbackErr.clientStatusCode(), textFallbackErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(dataBytes)) + if !retryable && !clientDisconnected { + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBodyFromUpstream(textFallbackErr)) + } + processDataErr = textFallbackErr + processDataDone = true + return + } outputErr := fmt.Errorf("upstream did not return image output") - // 软失败:response.completed 事件里没有图片。记录上游诊断摘要到 ops, - // 与非流式路径保持一致,避免上游响应信息丢失。 setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(dataBytes)) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(outputErr.Error())) processDataErr = outputErr @@ -1712,13 +1781,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( } upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Accept", "text/event-stream") + upstreamReq.Header.Set("OpenAI-Beta", "responses=experimental") proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) @@ -1760,11 +1830,15 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( Message: upstreamMsg, }) shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, requestModel) - return nil, &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseBody: respBody, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), - } + return nil, s.newOpenAIAccountFailoverError( + account, + resp.StatusCode, + resp.Header, + respBody, + upstreamMsg, + shouldDisable, + !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + ) } return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel) } @@ -1843,6 +1917,25 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( }, nil } +const ( + openAIImagesOAuthUnavailableCooldown = 30 * time.Minute + openAIImagesOAuthUnavailableReason = "openai_images_oauth_tool_unavailable" +) + +func (s *OpenAIGatewayService) coolOpenAIImagesOAuthTool(ctx context.Context, account *Account) { + if s == nil || s.accountRepo == nil || account == nil || account.Platform != PlatformOpenAI { + return + } + stateCtx, cancel := openAIAccountStateContext(ctx) + defer cancel() + resetAt := time.Now().Add(openAIImagesOAuthUnavailableCooldown) + if err := s.accountRepo.SetModelRateLimit(stateCtx, account.ID, openAIImageGenerationRateLimitKey, resetAt, openAIImagesOAuthUnavailableReason); err != nil { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images OAuth tool cooldown write failed account_id=%d error=%v", account.ID, err) + return + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images OAuth tool unavailable account_id=%d reset_in=%s", account.ID, time.Until(resetAt).Truncate(time.Second)) +} + func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError( ctx context.Context, c *gin.Context, @@ -1879,8 +1972,15 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError( } responseBody := []byte(fmt.Sprintf(`{"error":{"type":"upstream_error","code":%q,"message":%q}}`, code, message)) shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, headers, responseBody, requestedModel) - return &UpstreamFailoverError{StatusCode: statusCode, ResponseBody: responseBody, ResponseHeaders: headers, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)} + return s.newOpenAIAccountFailoverError( + account, + statusCode, + headers, + responseBody, + message, + shouldDisable, + !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode), + ) } var upstreamErr *OpenAIImagesUpstreamError if !errors.As(err, &upstreamErr) { @@ -1915,16 +2015,33 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError( Message: upstreamErr.clientMessage(), }) + responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr) + if upstreamErr.Code == "image_generation_unavailable" { + s.coolOpenAIImagesOAuthTool(ctx, account) + if responseWritten { + return err + } + return s.newOpenAIAccountFailoverError( + account, + upstreamErr.StatusCode, + headers, + responseBody, + upstreamErr.clientMessage(), + false, + false, + ) + } if !retryable || responseWritten { return err } - - responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr) shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.StatusCode, headers, responseBody, requestedModel) - return &UpstreamFailoverError{ - StatusCode: upstreamErr.StatusCode, - ResponseBody: responseBody, - ResponseHeaders: headers, - RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode), - } + return s.newOpenAIAccountFailoverError( + account, + upstreamErr.StatusCode, + headers, + responseBody, + upstreamErr.clientMessage(), + shouldDisable, + !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode), + ) } diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index a1bf5caf7b..f06c213f46 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -12,6 +12,7 @@ import ( "net/textproto" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" @@ -471,6 +472,17 @@ func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T) require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative)) } +func TestAccountSupportsOpenAIImageCapability_SetupTokenSupportsNative(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + } + + require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityBasic)) + require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative)) + require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings)) +} + func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok(t *testing.T) { account := &Account{ Platform: PlatformGrok, @@ -581,6 +593,67 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings)) }) + t.Run("空 openai_capabilities({})与未配置一致,不排除 OAuth 文本调度", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "openai_capabilities": map[string]any{}, + }, + } + + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + }) + + t.Run("空 openai_capabilities([]any)与未配置一致,不排除 OAuth 文本调度", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "openai_capabilities": []any{}, + }, + } + + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) + }) + + t.Run("空 openai_capabilities([]string)与未配置一致,不排除 OAuth 文本调度", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "openai_capabilities": []string{}, + }, + } + + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) + }) + + t.Run("非空但全 false 的 map 仍按显式禁用处理,不默认放行", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "openai_capabilities": map[string]any{"chat_completions": false}, + }, + } + + require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) + }) + + t.Run("类型异常(字符串)仍视为已配置但不含能力,不默认放行", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "openai_capabilities": "chat_completions", + }, + } + + require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) + }) + t.Run("未知能力不应默认放行", func(t *testing.T) { account := &Account{ Platform: PlatformOpenAI, @@ -774,7 +847,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, "acct-123", upstream.lastReq.Header.Get("chatgpt-account-id")) - require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) + require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Equal(t, openAIImagesResponsesMainModel, gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) @@ -1039,6 +1112,34 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailo require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode) } +func TestOpenAIGatewayServiceForwardImages_OAuth429CarriesSameAccountRetryWindow(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`) + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + svc := &OpenAIGatewayService{httpUpstream: &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"1"}, "X-Request-Id": []string{"req_img_oauth_429"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)), + }}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + account := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "token-123"}} + startedAt := time.Now() + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, time.Second, failoverErr.SameAccountRetryDelay) + require.WithinDuration(t, startedAt.Add(openAIOAuth429RetryWindow), failoverErr.SameAccountRetryDeadline, time.Second) +} + func TestOpenAIImagesOAuthBodyReadTransportErrorFailover(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -1291,6 +1392,52 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseU require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String()) } +func TestOpenAIGatewayServiceForwardImages_APIKeyAccessStateUsesTypedFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat"}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := []byte(`{"error":{"code":"organization_suspended","message":"Organization has been suspended"}}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_images_access_state"}, + }, + Body: io.NopCloser(bytes.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + account := &Account{ + ID: 51, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusForbidden, failoverErr.StatusCode) + require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage) + require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope) + require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason) + require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction) + require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode) + require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage) + require.False(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, "req_images_access_state", failoverErr.ResponseHeaders.Get("x-request-id")) + require.False(t, c.Writer.Written()) +} + func TestOpenAIGatewayServiceForwardImages_APIKeyStreamJSONResponseBillsImage(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`) @@ -1816,6 +1963,21 @@ func TestBuildOpenAIImagesResponsesRequest_PassesThroughNForMultiImageModels(t * require.Equal(t, "draw a cat", gjson.GetBytes(body, "input.0.content.0.text").String()) } +func TestBuildOpenAIImagesResponsesRequest_ForcesImageToolChoice(t *testing.T) { + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesGenerationsEndpoint, + Model: "gpt-image-2", + Prompt: "draw a cat", + } + + body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2") + require.NoError(t, err) + require.NotNil(t, body) + require.Equal(t, "image_generation", gjson.GetBytes(body, "tool_choice.type").String()) + require.Equal(t, "image_generation", gjson.GetBytes(body, "tools.0.type").String()) + require.Equal(t, "gpt-image-2", gjson.GetBytes(body, "tools.0.model").String()) +} + func TestBuildOpenAIImagesResponsesRequest_DoesNotPassNForDallE3(t *testing.T) { parsed := &OpenAIImagesRequest{ Endpoint: openAIImagesGenerationsEndpoint, @@ -1849,6 +2011,21 @@ func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) { require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String()) } +func TestBuildOpenAIImagesResponsesRequest_RequiresVerbatimUserPrompt(t *testing.T) { + prompt := "画一个蓝色马克杯,杯身只写“SkelOT”,保持大小写;白色背景,不要增加其他文字。" + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesGenerationsEndpoint, + Model: "gpt-image-2", + Prompt: prompt, + N: 1, + } + + body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2") + require.NoError(t, err) + require.Equal(t, openAIImagesVerbatimPromptInstructions, gjson.GetBytes(body, "instructions").String()) + require.Equal(t, prompt, gjson.GetBytes(body, "input.0.content.0.text").String()) +} + func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testing.T) { body := []byte( "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000004}}\n\n" + diff --git a/backend/internal/service/openai_json_decode.go b/backend/internal/service/openai_json_decode.go new file mode 100644 index 0000000000..d0366f06af --- /dev/null +++ b/backend/internal/service/openai_json_decode.go @@ -0,0 +1,24 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "io" +) + +func decodeOpenAIJSONUseNumber(data []byte, dst any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(dst); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err + } + return nil +} diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 0d9f105549..68d3bb725e 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -304,7 +304,7 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall( upstreamReq.Header.Set(liveAttestationHeader, attestation) applyLiveUpstreamIdentityHeaders(upstreamReq.Header) - resp, err := s.httpUpstream.Do(upstreamReq, resolveAccountProxyURL(account), account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, resolveAccountProxyURL(account), account) if err != nil { logLiveCreateStageFailure(ctx, account.ID, "upstream_transport", err) return nil, err diff --git a/backend/internal/service/openai_messages_dispatch.go b/backend/internal/service/openai_messages_dispatch.go index aedfb1b3f7..3495bc9e09 100644 --- a/backend/internal/service/openai_messages_dispatch.go +++ b/backend/internal/service/openai_messages_dispatch.go @@ -79,6 +79,13 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string { return xai.ModelMappingWithOptions(opts)["claude-*"] } + // 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的 + // gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级 + // model_mapping;anthropic 协议上游本身接受 claude-* 模型名。 + if IsCNProvider(g.Platform) { + return "" + } + cfg := normalizeOpenAIMessagesDispatchModelConfig(g.MessagesDispatchModelConfig) if mappedModel := strings.TrimSpace(cfg.ExactModelMappings[requestedModel]); mappedModel != "" { return mappedModel @@ -109,7 +116,9 @@ func sanitizeGroupMessagesDispatchFields(g *Group) { if g == nil || g.Platform == PlatformOpenAI { return } - g.AllowMessagesDispatch = false + if g.Platform != PlatformComposite { + g.AllowMessagesDispatch = false + } g.DefaultMappedModel = "" g.MessagesDispatchModelConfig = OpenAIMessagesDispatchModelConfig{} } diff --git a/backend/internal/service/openai_messages_dispatch_test.go b/backend/internal/service/openai_messages_dispatch_test.go index fd6dea86fa..2856729624 100644 --- a/backend/internal/service/openai_messages_dispatch_test.go +++ b/backend/internal/service/openai_messages_dispatch_test.go @@ -69,3 +69,25 @@ func TestSanitizeGroupMessagesDispatchFields_ClearsNonOpenAIPlatform(t *testing. require.Empty(t, group.DefaultMappedModel) require.Equal(t, OpenAIMessagesDispatchModelConfig{}, group.MessagesDispatchModelConfig) } + +func TestSanitizeGroupMessagesDispatchFields_PreservesCompositeDispatchToggle(t *testing.T) { + t.Parallel() + + group := &Group{ + Platform: PlatformComposite, + AllowMessagesDispatch: true, + DefaultMappedModel: "gpt-5.6-sol", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + SonnetMappedModel: "gpt-5.3-codex", + ExactModelMappings: map[string]string{ + "claude-fable-5": "gpt-5.6-sol", + }, + }, + } + + sanitizeGroupMessagesDispatchFields(group) + + require.True(t, group.AllowMessagesDispatch) + require.Empty(t, group.DefaultMappedModel) + require.Equal(t, OpenAIMessagesDispatchModelConfig{}, group.MessagesDispatchModelConfig) +} diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index 7107a706ad..75e8a07463 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -222,6 +222,126 @@ func TestResolveOpenAICompactForwardModel(t *testing.T) { } } +func TestResolveOpenAIForwardMappedModels_CompactMappingPrecedence(t *testing.T) { + conflictingMappings := map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"}, + "compact_model_mapping": map[string]any{"gpt-5.5": "gpt-5.5-openai-compact"}, + } + mappedOnlyCompact := map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"}, + "compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"}, + } + tests := []struct { + name string + account *Account + requireCompact bool + wantBilling string + wantUpstream string + }{ + { + name: "compact uses client-visible model before ordinary mapping", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: conflictingMappings}, + requireCompact: true, + wantBilling: "gpt-5.4", + wantUpstream: "gpt-5.5-openai-compact", + }, + { + name: "non-compact uses ordinary mapping", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: conflictingMappings}, + wantBilling: "gpt-5.4", + wantUpstream: "gpt-5.4", + }, + { + name: "compact falls back to ordinary mapped model", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: mappedOnlyCompact}, + requireCompact: true, + wantBilling: "gpt-5.4", + wantUpstream: "gpt-5.4-openai-compact", + }, + { + name: "passthrough ignores ordinary mapping", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: conflictingMappings, Extra: map[string]any{"openai_passthrough": true}}, + requireCompact: true, + wantBilling: "gpt-5.5", + wantUpstream: "gpt-5.5-openai-compact", + }, + { + name: "raw chat fallback never applies compact mapping", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: conflictingMappings, Extra: map[string]any{"openai_responses_supported": false}}, + requireCompact: true, + wantBilling: "gpt-5.4", + wantUpstream: "gpt-5.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + billing, upstream := resolveOpenAIForwardMappedModels(tt.account, "gpt-5.5", tt.requireCompact) + if billing != tt.wantBilling { + t.Fatalf("billing model = %q, want %q", billing, tt.wantBilling) + } + if upstream != tt.wantUpstream { + t.Fatalf("upstream model = %q, want %q", upstream, tt.wantUpstream) + } + if scheduler := resolveOpenAIAccountUpstreamModelForRequest(tt.account, "gpt-5.5", tt.requireCompact); scheduler != upstream { + t.Fatalf("scheduler model %q disagrees with Forward model %q", scheduler, upstream) + } + }) + } +} + +func TestCanonicalOpenAIAccountSchedulingModelMatchesForwardSemantics(t *testing.T) { + tests := []struct { + name string + account *Account + model string + want string + }{ + { + name: "OpenAI OAuth applies Codex alias normalization", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + model: "gpt-5.6", + want: "gpt-5.6-sol", + }, + { + name: "OpenAI passthrough ignores ordinary account mapping", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"model_mapping": map[string]any{"public": "private"}}, + Extra: map[string]any{"openai_passthrough": true}}, + model: "public", + want: "public", + }, + { + name: "Grok OAuth does not inherit OpenAI Codex aliases", + account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, + model: "gpt-5.6", + want: "gpt-5.6", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := canonicalOpenAIAccountSchedulingModel(tt.account, tt.model); got != tt.want { + t.Fatalf("canonical scheduling model = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveOpenAIErrorSchedulingModelPrefersActualUpstreamModel(t *testing.T) { + if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", "gpt-5.5-openai-compact"); got != "gpt-5.5-openai-compact" { + t.Fatalf("error scheduling model = %q, want compact upstream model", got) + } + if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", ""); got != "gpt-5.4" { + t.Fatalf("empty upstream fallback = %q, want billing model", got) + } +} + func TestNormalizeCodexModel(t *testing.T) { cases := map[string]string{ "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 2b3b00176f..fb9a6fee27 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -666,7 +666,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t * setOpenAIResponsesNamespaceNames(c, names) result, err := (&OpenAIGatewayService{cfg: &config.Config{}}).handleNonStreamingResponsePassthrough( - context.Background(), resp, c, "gpt-5.5", "", + context.Background(), resp, c, &Account{ID: 91}, "gpt-5.5", "", ) require.NoError(t, err) require.NotNil(t, result) @@ -735,12 +735,17 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactUsesJSONAndKeepsNonStreami } account := &Account{ - ID: 123, - Name: "acc", - Platform: PlatformOpenAI, - Type: AccountTypeOAuth, - Concurrency: 1, - Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + ID: 123, + Name: "acc", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + "model_mapping": map[string]any{"gpt-5.1-codex": "gpt-5.1-account"}, + "compact_model_mapping": map[string]any{"gpt-5.1-codex": "gpt-5.1-compact"}, + }, Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, @@ -754,7 +759,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactUsesJSONAndKeepsNonStreami require.False(t, gjson.GetBytes(upstream.lastBody, "store").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists()) - require.Equal(t, "gpt-5.1-codex", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "gpt-5.1-compact", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "compact me", gjson.GetBytes(upstream.lastBody, "input.0.text").String()) require.Equal(t, "local-test-instructions", strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String())) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) @@ -1943,6 +1948,212 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testin require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } +func TestOpenAIGatewayService_CodexFingerprintHTTPTransformedHeaderBodyParityAndDefaultCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"header-install","session_id":"header-session","thread_id":"header-thread","turn_id":"header-turn","window_id":"header-window","sandbox":"seatbelt"}`) + + body := []byte(`{"model":"gpt-5.2","stream":false,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session","x-codex-turn-metadata":"{\"installation_id\":\"body-install\",\"session_id\":\"body-session\",\"thread_id\":\"body-thread\",\"turn_id\":\"body-turn\",\"window_id\":\"body-window\",\"sandbox\":\"seatbelt\"}"},"input":[{"type":"message","role":"user","content":"hi"}]}`) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + toolCorrector: NewCodexToolCorrector(), + } + account := newTestOAuthAccount(4401, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Name = "oauth-transformed" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantInstall := resolveConvergedInstallationID(account, seed) + wantSession := resolveConvergedSessionID(seed) + wantThread := resolveConvergedThreadID(seed, "header-session") + + require.Equal(t, wantInstall, upstream.lastReq.Header.Get("x-codex-installation-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("thread-id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("x-client-request-id")) + require.Equal(t, wantThread+":0", upstream.lastReq.Header.Get("x-codex-window-id")) + + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, wantInstall, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").String()) + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.Equal(t, wantThread, gjson.GetBytes(upstream.lastBody, "client_metadata.thread_id").String()) + require.Equal(t, wantThread+":0", gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-window-id").String()) + + bodyTurnMetadata := gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-turn-metadata").String() + headerTurnMetadata := upstream.lastReq.Header.Get("x-codex-turn-metadata") + require.Equal(t, wantSession, gjson.Get(bodyTurnMetadata, "session_id").String()) + require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) + require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) +} + +func TestOpenAIGatewayService_CodexFingerprintHTTPRawPassthroughHeaderBodyParityAndDefaultCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"header-install","session_id":"header-session","thread_id":"header-thread","turn_id":"header-turn","window_id":"header-window","sandbox":"seatbelt"}`) + + body := []byte(`{"model":"gpt-5.6-sol","stream":false,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session","x-codex-turn-metadata":"{\"installation_id\":\"body-install\",\"session_id\":\"body-session\",\"thread_id\":\"body-thread\",\"turn_id\":\"body-turn\",\"window_id\":\"body-window\",\"sandbox\":\"seatbelt\"}"},"input":[{"type":"message","role":"user","content":"hi"}]}`) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := newTestOAuthAccount(4402, map[string]any{ + codexFingerprintModeExtraKey: "session", + "openai_oauth_passthrough": true, + }) + account.Name = "oauth-raw" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantInstall := resolveConvergedInstallationID(account, seed) + wantSession := resolveConvergedSessionID(seed) + wantThread := resolveConvergedThreadID(seed, "header-session") + + require.Equal(t, wantInstall, upstream.lastReq.Header.Get("x-codex-installation-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("thread-id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("x-client-request-id")) + require.Equal(t, wantThread+":0", upstream.lastReq.Header.Get("x-codex-window-id")) + + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, wantInstall, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").String()) + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.Equal(t, wantThread, gjson.GetBytes(upstream.lastBody, "client_metadata.thread_id").String()) + require.Equal(t, wantThread+":0", gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-window-id").String()) + + bodyTurnMetadata := gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-turn-metadata").String() + headerTurnMetadata := upstream.lastReq.Header.Get("x-codex-turn-metadata") + require.Equal(t, wantSession, gjson.Get(bodyTurnMetadata, "session_id").String()) + require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) + require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) +} + +func TestOpenAIGatewayService_CodexFingerprintCompactDoesNotRewriteBodyCacheKeyOrMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + + body := []byte(`{"model":"gpt-5.4","stream":false,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"},"input":[{"type":"message","role":"user","content":"compress"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + toolCorrector: NewCodexToolCorrector(), + } + account := newTestOAuthAccount(4403, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Name = "oauth-compact" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + staleIDs := resolveCodexFingerprintIDs(account, "stale-session", codexFingerprintSession) + require.NotNil(t, staleIDs) + stageCodexFingerprintIDs(c, staleIDs) + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + require.NotEqual(t, resolveConvergedSessionID(seed), gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, "body-session", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, "body-session", gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").Exists()) + require.Empty(t, upstream.lastReq.Header.Get("x-codex-window-id")) +} + +func TestOpenAIGatewayService_CodexFingerprintMessagesBridgeDoesNotInjectBodyPromptCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + + body := []byte(`{"model":"gpt-5.5","stream":true,"prompt_cache_key":"anthropic-metadata-session-1","client_metadata":{"session_id":"anthropic-metadata-session-1"},"input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"` + openAICompatClaudeCodeTodoGuardMarker + `"}]},{"type":"message","role":"user","content":"hello"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + toolCorrector: NewCodexToolCorrector(), + } + account := newTestOAuthAccount(4404, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Name = "oauth-messages-bridge" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantSession := resolveConvergedSessionID(seed) + require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) +} + func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) { gin.SetMode(gin.TestMode) @@ -2172,12 +2383,16 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd } account := &Account{ - ID: 456, - Name: "apikey-acc", - Platform: PlatformOpenAI, - Type: AccountTypeAPIKey, - Concurrency: 1, - Credentials: map[string]any{"api_key": "sk-api-key", "base_url": "https://api.openai.com"}, + ID: 456, + Name: "apikey-acc", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-api-key", + "base_url": "https://api.openai.com", + "model_mapping": map[string]any{"gpt-5.2": "gpt-5.2-account"}, + }, Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index 15b803a7de..f86ecc6927 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -20,6 +20,216 @@ func TestNormalizeOpenAIPassthroughOAuthBody_RemovesUnsupportedUser(t *testing.T require.False(t, gjson.GetBytes(normalized, "store").Bool()) } +func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesCompatibilityFields(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","prompt":"hello","commands":["unsupported"],"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`) + + normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "hello", gjson.GetBytes(normalized, "input.0.content").String()) + for _, field := range []string{"prompt", "commands", "truncation", "stop_sequences", "chat_template_kwargs"} { + require.False(t, gjson.GetBytes(normalized, field).Exists(), field) + } +} + +func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesReasoningMode(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","input":"hello","reasoning":{"mode":"pro"}}`) + + normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) +} + +func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":"explicit","prompt":"legacy"}`) + + normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "explicit", gjson.GetBytes(normalized, "input").String()) + require.False(t, gjson.GetBytes(normalized, "prompt").Exists()) +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) { + body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`) + + oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String()) + for _, field := range []string{"prompt", "commands", "truncation", "stop_sequences", "chat_template_kwargs"} { + require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field) + } + + apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) + require.NoError(t, err) + require.False(t, changed) + require.JSONEq(t, string(body), string(apiKeyBody)) +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemIDs(t *testing.T) { + body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[` + + `{"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},` + + `{"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},` + + `{"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},` + + `{"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}}]}`) + + for _, oauth := range []bool{false, true} { + accountType := AccountTypeAPIKey + if oauth { + accountType = AccountTypeOAuth + } + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String()) + require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists()) + require.Equal(t, "ctc_valid", gjson.GetBytes(normalized, "input.1.id").String()) + require.False(t, gjson.GetBytes(normalized, "input.2.id").Exists()) + require.Equal(t, "tsc_valid", gjson.GetBytes(normalized, "input.3.id").String()) + // Native Responses call_id values are correlation keys, not item IDs. + require.Equal(t, "call_custom_1", gjson.GetBytes(normalized, "input.0.call_id").String()) + require.Equal(t, "call_search_1", gjson.GetBytes(normalized, "input.2.call_id").String()) + } +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseReplay(t *testing.T) { + body := []byte(`{"type":"response.create","store":false,"parallel_tool_calls":true,"input":[` + + `{"type":"reasoning","id":"rs_drop","summary":[]},` + + `{"type":"reasoning","id":"rs_keep","call_id":"remove","encrypted_content":"cipher"},` + + `{"type":"message","content":"continue"}` + + `]}`) + + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + }, false) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) + require.Equal(t, int64(2), gjson.GetBytes(normalized, "input.#").Int()) + require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists()) + require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists()) + require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray()) + require.Equal(t, "message", gjson.GetBytes(normalized, "input.1.type").String()) +} + +func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { + tests := []struct { + name string + body string + wantEffort string + }{ + {name: "pro maps to max", body: `{"reasoning":{"mode":"pro"}}`, wantEffort: "max"}, + {name: "explicit effort wins", body: `{"reasoning":{"mode":"pro","effort":"high"}}`, wantEffort: "high"}, + {name: "other mode only removed", body: `{"reasoning":{"mode":"standard"}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) + require.Equal(t, tt.wantEffort, gjson.GetBytes(normalized, "reasoning.effort").String()) + }) + } +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) { + body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`) + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) + } + apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) + require.NoError(t, err) + require.False(t, changed) + require.JSONEq(t, string(body), string(apiKeyBody)) +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) { + body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`) + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String()) + require.False(t, gjson.GetBytes(normalized, "tools.0.parameters.properties.q.pattern").Exists()) + } +} + +func TestNormalizeOpenAIResponseFormatSchemasBody_PreservesNonStrictOptionalFields(t *testing.T) { + body := []byte(`{"text":{"format":{"type":"json_schema","strict":false,"schema":{"properties":{"tags":{"items":{"type":"string"},"uniqueItems":true}},"minProperties":1,"maxProperties":4}}}}`) + + normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.type").String()) + require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.properties.tags.type").String()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.minProperties").Exists()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.properties.tags.uniqueItems").Exists()) + require.Equal(t, int64(4), gjson.GetBytes(normalized, "text.format.schema.maxProperties").Int()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.required").Exists()) +} + +func TestNormalizeOpenAIResponseFormatSchemasBody_DoesNotExpandStrictSchema(t *testing.T) { + body := []byte(`{"response_format":{"type":"json_schema","json_schema":{"strict":true,"schema":{"properties":{"name":{"type":"string"}}}}}}`) + + normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body) + require.NoError(t, err) + require.True(t, changed) // Safe type inference still applies. + require.Equal(t, "object", gjson.GetBytes(normalized, "response_format.json_schema.schema.type").String()) + require.False(t, gjson.GetBytes(normalized, "response_format.json_schema.schema.required").Exists()) + require.False(t, gjson.GetBytes(normalized, "response_format.json_schema.schema.additionalProperties").Exists()) +} + +func TestNormalizeOpenAIResponseFormatSchemasBody_TraversesNestedSchemaContainers(t *testing.T) { + body := []byte(`{ + "text":{"format":{"type":"json_schema","schema":{ + "$defs":{"entry":{"properties":{"name":{"type":"string"}},"minProperties":1,"maxProperties":3}}, + "additionalProperties":{"items":{"type":"string"},"uniqueItems":true}, + "prefixItems":[{"properties":{"id":{"type":"string"}},"minProperties":1}], + "dependentSchemas":{"kind":{"properties":{"value":{"type":"string"}},"uniqueItems":true}}, + "not":{"items":{"type":"string"},"minProperties":1} + }}} + }`) + + normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.$defs.entry.type").String()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.$defs.entry.minProperties").Exists()) + require.Equal(t, int64(3), gjson.GetBytes(normalized, "text.format.schema.$defs.entry.maxProperties").Int()) + require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.additionalProperties.type").String()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.additionalProperties.uniqueItems").Exists()) + require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.prefixItems.0.type").String()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.prefixItems.0.minProperties").Exists()) + require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.dependentSchemas.kind.type").String()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.dependentSchemas.kind.uniqueItems").Exists()) + require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.not.type").String()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.not.minProperties").Exists()) + require.False(t, gjson.GetBytes(normalized, "text.format.schema.required").Exists()) +} + +func TestNormalizeOpenAIResponseFormatSchemasBody_PreservesExistingTypeValues(t *testing.T) { + body := []byte(`{"text":{"format":{"type":"json_schema","schema":{"properties":{"union":{"type":["object","null"],"properties":{"name":{"type":"string"}}},"custom":{"type":{"vendor":"shape"},"properties":{"id":{"type":"string"}}},"inferred":{"type":null,"items":{"type":"string"}}}}}}}`) + + normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.type").String()) + require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.properties.union.type.0").String()) + require.Equal(t, "null", gjson.GetBytes(normalized, "text.format.schema.properties.union.type.1").String()) + require.True(t, gjson.GetBytes(normalized, "text.format.schema.properties.custom.type").IsObject()) + require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.properties.inferred.type").String()) +} + func TestNormalizeOpenAIPassthroughOAuthBody_CompactRemovesUnsupportedUser(t *testing.T) { body := []byte(`{"model":"gpt-5.4","input":"hello","user":"user_123","metadata":{"user_id":"user_123"},"stream":true,"store":true}`) diff --git a/backend/internal/service/openai_plugin_transport.go b/backend/internal/service/openai_plugin_transport.go new file mode 100644 index 0000000000..82b8d359ee --- /dev/null +++ b/backend/internal/service/openai_plugin_transport.go @@ -0,0 +1,45 @@ +package service + +import "net/http" + +func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) { + s.pluginManager = manager +} + +// doOpenAIUpstream 只在 OpenAI OAuth 能力绑定已启用时把真实请求交给插件。 +// 插件返回标准 http.Response,响应解析、错误映射、SSE 和计费仍由现有核心链处理。 +func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL string, account *Account) (*http.Response, error) { + if s.pluginManager != nil { + response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account) + if handled { + return response, err + } + } + return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency) +} + +// doOpenAIAccountTestUpstream 让 OpenAI OAuth 账号测试与真实转发使用同一插件路径。 +// API Key 和未命中插件的账号保持各自原有的 HTTPUpstream 行为。 +func (s *AccountTestService) doOpenAIAccountTestUpstream( + request *http.Request, + proxyURL string, + account *Account, + useTLSFallback bool, +) (*http.Response, error) { + if s.pluginManager != nil { + response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account) + if handled { + return response, err + } + } + if useTLSFallback { + return s.httpUpstream.DoWithTLS( + request, + proxyURL, + account.ID, + account.Concurrency, + s.tlsFPProfileService.ResolveTLSProfile(account), + ) + } + return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency) +} diff --git a/backend/internal/service/openai_profit_control.go b/backend/internal/service/openai_profit_control.go index 4153bc4094..a3418e6fcf 100644 --- a/backend/internal/service/openai_profit_control.go +++ b/backend/internal/service/openai_profit_control.go @@ -342,7 +342,7 @@ func (s *OpenAIGatewayService) ProfitControlVetoLatest(ctx context.Context, sele // only after the terminal post-slot check, so an account rejected after a rate // refresh cannot become the new sticky target. func (s *OpenAIGatewayService) bindOpenAIStickySessionDuringSelection(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { - if gatewayProfitControlGateActive(ctx) { + if gatewayProfitControlGateActive(ctx) || preserveOpenAIGuardianParentBinding(ctx, sessionHash) { return nil } return s.BindStickySession(ctx, groupID, sessionHash, accountID) @@ -358,6 +358,9 @@ func (s *OpenAIGatewayService) BindStickySessionAfterProfitAdmission(ctx context if sessionHash == "" || accountID <= 0 { return nil } + if preserveOpenAIGuardianParentBinding(ctx, sessionHash) { + return nil + } if !gatewayProfitControlGateActive(ctx) { return s.BindStickySession(ctx, groupID, sessionHash, accountID) } diff --git a/backend/internal/service/openai_profit_control_pricing_test.go b/backend/internal/service/openai_profit_control_pricing_test.go index 03077d2a8b..379789f200 100644 --- a/backend/internal/service/openai_profit_control_pricing_test.go +++ b/backend/internal/service/openai_profit_control_pricing_test.go @@ -1,7 +1,7 @@ package service // 请求级定价与利润门回归:请求级 pricingAt 定价上下文、门复用(failover 阈值稳定)、 -// 生图意图跳门、U 使用账号倍率且与探测新鲜度解耦、 +// Responses 文本能力利润门、U 使用账号倍率且与探测新鲜度解耦、 // 用量记录定价时刻取值。 import ( @@ -109,8 +109,9 @@ func TestProfitControl_UsesAccountRateInsteadOfProbeSnapshot(t *testing.T) { require.Equal(t, openAIProfitFilterReasonThreshold, reason) } -// 显式生图意图(requiredCapability=Responses)在唯一调度入口跳门(图片边界不装门)。 -func TestProfitControl_ResponsesImageIntentSkipsGateAtScheduler(t *testing.T) { +// Responses 是端点能力,不代表媒体请求;原生远程压缩同样要求该能力, +// 因此唯一文本调度入口必须照常安装利润门。 +func TestProfitControl_ResponsesCapabilityUsesTextGateAtScheduler(t *testing.T) { now := time.Now() expensive := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) expensive.Status = StatusActive @@ -129,12 +130,8 @@ func TestProfitControl_ResponsesImageIntentSkipsGateAtScheduler(t *testing.T) { require.ErrorIs(t, err, ErrNoAvailableAccounts, "文本能力必须过利润门") selection, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, false, false, true) - require.NoError(t, err, "生图意图不装门,保持既有调度") - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.ErrorIs(t, err, ErrNoAvailableAccounts, "Responses 文本能力不得绕过利润门") + require.Nil(t, selection) } // 账号倍率缺失一律视为非法保守拒绝;手工或同步维护了倍率的任意账号类型都按 diff --git a/backend/internal/service/openai_quota_auto_reset.go b/backend/internal/service/openai_quota_auto_reset.go new file mode 100644 index 0000000000..aafc5991d7 --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset.go @@ -0,0 +1,809 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "sort" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/google/uuid" +) + +const ( + openAIAutoResetScanInterval = time.Minute + openAIAutoResetSnapshotTTL = openAIProbeCacheTTL + openAIAutoResetBatchSize = 100 + openAIAutoResetWorkerCount = 4 + openAIAutoResetQueueCapacity = 1024 + openAIAutoResetAttemptTTL = 8 * 24 * time.Hour + openAIAutoResetLeaderLockKey = "jobs:openai-auto-reset-credit" +) + +const ( + OpenAIAutoResetStatusChecking = "checking" + OpenAIAutoResetStatusAvailable = "available" + OpenAIAutoResetStatusResetting = "resetting" + OpenAIAutoResetStatusSuccess = "success" + OpenAIAutoResetStatusNoCredit = "no_credit" + OpenAIAutoResetStatusFailed = "failed" +) + +// OpenAIAutoResetCreditState 是可返回管理端的脱敏运行态。Attempt* 仅保存不可逆 +// 指纹,用于重启后拒绝切换到另一张卡;不会保存卡 ID 或兑换 ID。 +type OpenAIAutoResetCreditState struct { + Status string `json:"status"` + TriggerWindow string `json:"trigger_window,omitempty"` + AvailableCount int `json:"available_count"` + CheckedAt string `json:"checked_at,omitempty"` + LastResultAt string `json:"last_result_at,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + AttemptCycleHash string `json:"attempt_cycle_hash,omitempty"` + AttemptCreditHash string `json:"attempt_credit_hash,omitempty"` +} + +type openAIAutoResetQuota interface { + QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error + ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) +} + +type openAIAutoResetContextKey struct{} + +func withOpenAIAutoResetContext(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, openAIAutoResetContextKey{}, true) +} + +func isOpenAIAutoResetContext(ctx context.Context) bool { + if ctx == nil { + return false + } + value, _ := ctx.Value(openAIAutoResetContextKey{}).(bool) + return value +} + +type openAIAutoResetRecovery interface { + RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) +} + +// OpenAIQuotaAutoResetService 通过小型去重队列承接实时信号,并用分钟扫描补偿 +// 重启、漏事件和多实例读取;真正消费仍由 PostgreSQL 幂等记录串行化。 +type OpenAIQuotaAutoResetService struct { + accountRepo AccountRepository + quota openAIAutoResetQuota + recoverer openAIAutoResetRecovery + idempotency *IdempotencyCoordinator + audit *AuditLogService + settings *SettingService + leaderLock LeaderLockCache + + ctx context.Context + cancel context.CancelFunc + queue chan int64 + pending sync.Map + owner string + start sync.Once + stop sync.Once + wg sync.WaitGroup +} + +func NewOpenAIQuotaAutoResetService( + accountRepo AccountRepository, + quota openAIAutoResetQuota, + recoverer openAIAutoResetRecovery, + idempotency *IdempotencyCoordinator, + audit *AuditLogService, + settings *SettingService, + leaderLock LeaderLockCache, +) *OpenAIQuotaAutoResetService { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenAIQuotaAutoResetService{ + accountRepo: accountRepo, + quota: quota, + recoverer: recoverer, + idempotency: idempotency, + audit: audit, + settings: settings, + leaderLock: leaderLock, + ctx: ctx, + cancel: cancel, + queue: make(chan int64, openAIAutoResetQueueCapacity), + owner: uuid.NewString(), + } +} + +func (s *OpenAIQuotaAutoResetService) Start() { + if s == nil || s.accountRepo == nil || s.quota == nil || s.idempotency == nil { + return + } + s.start.Do(func() { + setOpenAIAutoResetNotifier(s) + for range openAIAutoResetWorkerCount { + s.wg.Add(1) + go s.runWorker() + } + s.wg.Add(1) + go s.runScanner() + }) +} + +func (s *OpenAIQuotaAutoResetService) Stop() { + if s == nil { + return + } + s.stop.Do(func() { + clearOpenAIAutoResetNotifier(s) + s.cancel() + s.wg.Wait() + }) +} + +// Notify 是请求热路径的非阻塞入口。同一账号尚在队列时只保留一个任务;队列 +// 满时丢弃本次信号,分钟扫描仍会补偿,因此不会反向拖慢网关请求。 +func (s *OpenAIQuotaAutoResetService) Notify(accountID int64) { + if s == nil || accountID <= 0 { + return + } + if _, loaded := s.pending.LoadOrStore(accountID, struct{}{}); loaded { + return + } + select { + case <-s.ctx.Done(): + s.pending.Delete(accountID) + case s.queue <- accountID: + default: + s.pending.Delete(accountID) + slog.Warn("openai_auto_reset_queue_full", "account_id", accountID) + } +} + +func (s *OpenAIQuotaAutoResetService) runWorker() { + defer s.wg.Done() + for { + select { + case <-s.ctx.Done(): + return + case accountID := <-s.queue: + ctx, cancel := context.WithTimeout(s.ctx, 50*time.Second) + if err := s.evaluateAccount(ctx, accountID); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("openai_auto_reset_evaluate_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + } + cancel() + s.pending.Delete(accountID) + } + } +} + +func (s *OpenAIQuotaAutoResetService) runScanner() { + defer s.wg.Done() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + select { + case <-s.ctx.Done(): + return + case <-timer.C: + s.scanEnabledAccounts(s.ctx) + } + ticker := time.NewTicker(openAIAutoResetScanInterval) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.scanEnabledAccounts(s.ctx) + } + } +} + +func (s *OpenAIQuotaAutoResetService) scanEnabledAccounts(ctx context.Context) { + release, scan := s.tryAcquireScanLock(ctx) + if !scan { + return + } + if release != nil { + defer release() + } + for page := 1; ; page++ { + accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{ + Page: page, PageSize: openAIAutoResetBatchSize, + }, PlatformOpenAI, AccountTypeOAuth, StatusActive, "", 0, "") + if err != nil { + slog.Warn("openai_auto_reset_scan_failed", "page", page, "error", err) + return + } + for i := range accounts { + account := &accounts[i] + if account.Schedulable && ResolveOpenAIAutoResetCreditConfig(account).Enabled { + s.Notify(account.ID) + } + } + if len(accounts) < openAIAutoResetBatchSize || pageInfo == nil || page >= pageInfo.Pages { + return + } + } +} + +// Redis 锁异常时允许重复扫描,避免协调设施故障导致所有实例同时停止补偿; +// 消费唯一性由数据库幂等记录负责,扫描锁只用于削减重复查询。 +func (s *OpenAIQuotaAutoResetService) tryAcquireScanLock(ctx context.Context) (func(), bool) { + if s.leaderLock == nil { + return func() {}, true + } + ok, err := s.leaderLock.TryAcquireLeaderLock(ctx, openAIAutoResetLeaderLockKey, s.owner, 55*time.Second) + if err != nil { + slog.Warn("openai_auto_reset_leader_lock_unavailable", "error", err) + return func() {}, true + } + if !ok { + return nil, false + } + return func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.leaderLock.ReleaseLeaderLock(releaseCtx, openAIAutoResetLeaderLockKey, s.owner) + }, true +} + +type openAIAutoResetAssessment struct { + triggerWindow string + resetReached bool + pauseReached bool + utilization5h float64 + utilization7d float64 + threshold5h float64 + threshold7d float64 +} + +func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accountID int64) error { + ctx = withOpenAIAutoResetContext(ctx) + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + if account.IsShadow() { + if account.ParentAccountID != nil { + s.Notify(*account.ParentAccountID) + } + return nil + } + config := ResolveOpenAIAutoResetCreditConfig(account) + if !config.Enabled || !account.IsActive() || !account.Schedulable { + return nil + } + + now := time.Now() + assessment := s.assessExtra(account, config, now) + state := openAIAutoResetStateFromExtra(account.Extra) + needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached + if assessment.pauseReached && !assessment.resetReached { + needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now) + } + if !needsQuery { + if !assessment.pauseReached && state != nil && state.TriggerWindow != "" { + state.TriggerWindow = "" + state.ErrorCode = "" + state.CheckedAt = now.UTC().Format(time.RFC3339) + if state.AvailableCount > 0 { + state.Status = OpenAIAutoResetStatusAvailable + } else { + state.Status = OpenAIAutoResetStatusNoCredit + } + return s.persistState(ctx, accountID, state) + } + return nil + } + + checking := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusChecking, + TriggerWindow: assessment.triggerWindow, + AvailableCount: stateAvailableCount(state), + CheckedAt: now.UTC().Format(time.RFC3339), + } + copyOpenAIAutoResetAttempt(checking, state) + if err := s.persistState(ctx, accountID, checking); err != nil { + return err + } + + usage, err := s.quota.QueryUsage(ctx, accountID) + if err != nil || usage == nil { + return s.failState(ctx, accountID, checking, "RESET_CREDIT_QUERY_FAILED", err) + } + if err := s.persistFreshUsage(ctx, accountID, usage, now); err != nil { + return s.failState(ctx, accountID, checking, "USAGE_SNAPSHOT_WRITE_FAILED", err) + } + if usage.RateLimitResetCredits == nil { + return s.failState(ctx, accountID, checking, "RESET_CREDIT_DETAILS_UNAVAILABLE", nil) + } + + // 查询期间管理员可能关闭开关;消费前重新读取账号,确保尚未发出的任务可取消。 + account, err = s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + config = ResolveOpenAIAutoResetCreditConfig(account) + if !config.Enabled { + return nil + } + assessment = s.assessUsage(usage, account, config, now) + available := usage.RateLimitResetCredits.AvailableCount + if !assessment.resetReached { + status := OpenAIAutoResetStatusNoCredit + if available > 0 { + status = OpenAIAutoResetStatusAvailable + } + return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ + Status: status, + TriggerWindow: assessment.triggerWindow, + AvailableCount: available, + CheckedAt: now.UTC().Format(time.RFC3339), + }) + } + if available <= 0 { + return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, + TriggerWindow: assessment.triggerWindow, + AvailableCount: 0, + CheckedAt: now.UTC().Format(time.RFC3339), + LastResultAt: now.UTC().Format(time.RFC3339), + ErrorCode: "NO_RESET_CREDIT", + }) + } + + cycleSeed := openAIAutoResetCycleSeed(usage) + cycleHash := shortOpenAIAutoResetHash(cycleSeed) + candidate, selectErr := selectOpenAIAutoResetCandidate(usage.autoResetCandidates, available, state, cycleHash) + if selectErr != nil { + failed := checking + failed.AvailableCount = available + failed.TriggerWindow = assessment.triggerWindow + failed.AttemptCycleHash = cycleHash + return s.failState(ctx, accountID, failed, infraerrors.Reason(selectErr), selectErr) + } + creditHash := shortOpenAIAutoResetHash(candidate.ID) + stableKey := fmt.Sprintf("oarc:%d:%s:%s", accountID, creditHash, cycleHash) + redeemRequestID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(stableKey)).String() + resetting := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusResetting, + TriggerWindow: assessment.triggerWindow, + AvailableCount: available, + CheckedAt: now.UTC().Format(time.RFC3339), + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + if err := s.persistState(ctx, accountID, resetting); err != nil { + return err + } + + account, err = s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil || !ResolveOpenAIAutoResetCreditConfig(account).Enabled { + return err + } + result, err := s.idempotency.Execute(ctx, IdempotencyExecuteOptions{ + Scope: "openai_auto_reset_credit", + ActorScope: fmt.Sprintf("account:%d", accountID), + Method: http.MethodPost, + Route: "/system/openai/reset-credit/auto", + IdempotencyKey: stableKey, + Payload: map[string]any{ + "account_id": accountID, + "credit_hash": creditHash, + "cycle_hash": cycleHash, + }, + TTL: openAIAutoResetAttemptTTL, + RequireKey: true, + }, func(execCtx context.Context) (any, error) { + resetResult, resetErr := s.quota.ResetCreditTargeted(execCtx, accountID, candidate.ID, redeemRequestID) + if resetErr != nil { + return nil, resetErr + } + if resetResult == nil { + return nil, infraerrors.InternalServer("OPENAI_AUTO_RESET_EMPTY_RESULT", "automatic reset returned an empty result") + } + // 幂等表只保存脱敏结果,避免上游返回的卡 ID 被持久化到响应体列。 + return openAIAutoResetConsumeResult{Code: resetResult.Code, WindowsReset: resetResult.WindowsReset}, nil + }) + if err != nil { + // 另一个实例已持有同一周期的兑换时保持 resetting,等待下一轮读取同一 + // 幂等结果;不能把并发冲突误报成上游消费失败,更不能改选下一张卡。 + reason := infraerrors.Reason(err) + if reason == infraerrors.Reason(ErrIdempotencyInProgress) || reason == infraerrors.Reason(ErrIdempotencyRetryBackoff) { + return nil + } + s.recordAudit(accountID, assessment, available, "failed", 0, infraerrors.Reason(err)) + return s.failState(ctx, accountID, resetting, infraerrors.Reason(err), err) + } + + consumeResult := decodeOpenAIAutoResetConsumeResult(result.Data) + if strings.EqualFold(strings.TrimSpace(consumeResult.Code), "no_credit") { + noCreditAt := time.Now().UTC().Format(time.RFC3339) + noCredit := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, + TriggerWindow: assessment.triggerWindow, + AvailableCount: 0, + CheckedAt: noCreditAt, + LastResultAt: noCreditAt, + ErrorCode: "NO_RESET_CREDIT", + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + s.recordAudit(accountID, assessment, available, "no_credit", 0, noCredit.ErrorCode) + return s.persistState(ctx, accountID, noCredit) + } + postCtx, cancelPost := context.WithTimeout(context.WithoutCancel(ctx), 8*time.Second) + post := RunOpenAIQuotaResetPostProcess(postCtx, accountID, s.quota, s.recoverer, s.accountRepo.GetByID) + cancelPost() + if !post.AccountStateRecovered || post.WarningCode != "" { + code := post.WarningCode + if code == "" { + code = OpenAIQuotaResetWarningAccountRecoveryFailed + } + s.recordAudit(accountID, assessment, available, "recovery_failed", consumeResult.WindowsReset, code) + return s.failState(ctx, accountID, resetting, code, nil) + } + + successAt := time.Now().UTC().Format(time.RFC3339) + success := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusSuccess, + TriggerWindow: assessment.triggerWindow, + AvailableCount: max(0, available-1), + CheckedAt: successAt, + LastResultAt: successAt, + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + if post.Quota != nil && post.Quota.RateLimitResetCredits != nil { + success.AvailableCount = post.Quota.RateLimitResetCredits.AvailableCount + } + if err := s.persistState(ctx, accountID, success); err != nil { + return err + } + s.recordAudit(accountID, assessment, available, "success", consumeResult.WindowsReset, "") + slog.Info("openai_auto_reset_credit_success", + "account_id", accountID, + "trigger_window", assessment.triggerWindow, + "threshold_5h", assessment.threshold5h, + "threshold_7d", assessment.threshold7d, + "utilization_5h", assessment.utilization5h, + "utilization_7d", assessment.utilization7d, + "windows_reset", consumeResult.WindowsReset, + ) + return nil +} + +type openAIAutoResetConsumeResult struct { + Code string `json:"code"` + WindowsReset int `json:"windows_reset"` +} + +func decodeOpenAIAutoResetConsumeResult(value any) openAIAutoResetConsumeResult { + if typed, ok := value.(openAIAutoResetConsumeResult); ok { + return typed + } + raw, _ := json.Marshal(value) + var decoded openAIAutoResetConsumeResult + _ = json.Unmarshal(raw, &decoded) + return decoded +} + +func (s *OpenAIQuotaAutoResetService) assessExtra(account *Account, config OpenAIAutoResetCreditConfig, now time.Time) openAIAutoResetAssessment { + utilization5h, _ := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) + utilization7d, _ := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) + return s.buildAssessment(account, config, utilization5h, utilization7d) +} + +func (s *OpenAIQuotaAutoResetService) assessUsage(usage *OpenAIQuotaUsage, account *Account, config OpenAIAutoResetCreditConfig, now time.Time) openAIAutoResetAssessment { + updates := buildOpenAIAutoResetUsageUpdates(usage, now) + utilization5h := readOpenAIQuotaUsedPercent(updates, "5h") / 100 + utilization7d := readOpenAIQuotaUsedPercent(updates, "7d") / 100 + return s.buildAssessment(account, config, utilization5h, utilization7d) +} + +func (s *OpenAIQuotaAutoResetService) buildAssessment(account *Account, config OpenAIAutoResetCreditConfig, utilization5h, utilization7d float64) openAIAutoResetAssessment { + assessment := openAIAutoResetAssessment{ + utilization5h: utilization5h, + utilization7d: utilization7d, + threshold5h: config.Threshold5h, + threshold7d: config.Threshold7d, + } + reset5h := utilization5h >= config.Threshold5h + reset7d := utilization7d >= config.Threshold7d + assessment.resetReached = reset5h || reset7d + assessment.triggerWindow = joinOpenAIAutoResetWindows(reset5h, reset7d) + + pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(context.Background(), account) + if s.settings != nil { + pause5h, pause7d = resolveOpenAIQuotaAutoPauseThresholds( + withOpenAIQuotaAutoPauseSettings(context.Background(), s.settings.GetOpenAIQuotaAutoPauseSettings(context.Background())), + account, + ) + } + pauseReached5h := !resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") && pause5h > 0 && utilization5h >= pause5h + pauseReached7d := !resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") && pause7d > 0 && utilization7d >= pause7d + assessment.pauseReached = pauseReached5h || pauseReached7d || assessment.resetReached + if assessment.triggerWindow == "" { + assessment.triggerWindow = joinOpenAIAutoResetWindows(pauseReached5h, pauseReached7d) + } + return assessment +} + +func joinOpenAIAutoResetWindows(fiveHour, sevenDay bool) string { + switch { + case fiveHour && sevenDay: + return "5h+7d" + case fiveHour: + return "5h" + case sevenDay: + return "7d" + default: + return "" + } +} + +func buildOpenAIAutoResetUsageUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any { + if usage == nil || usage.RateLimit == nil { + return nil + } + rateLimit := usage.RateLimit + snapshot := &OpenAICodexUsageSnapshot{UpdatedAt: now.UTC().Format(time.RFC3339)} + applyWindow := func(window *OpenAIRateLimitWindow, primary bool) { + if window == nil { + return + } + used := window.UsedPercent + resetAfter := int(window.ResetAfterSeconds) + windowMinutes := int(window.LimitWindowSeconds / 60) + if primary { + snapshot.PrimaryUsedPercent = &used + snapshot.PrimaryResetAfterSeconds = &resetAfter + snapshot.PrimaryWindowMinutes = &windowMinutes + } else { + snapshot.SecondaryUsedPercent = &used + snapshot.SecondaryResetAfterSeconds = &resetAfter + snapshot.SecondaryWindowMinutes = &windowMinutes + } + } + applyWindow(rateLimit.PrimaryWindow, true) + applyWindow(rateLimit.SecondaryWindow, false) + return buildCodexUsageExtraUpdates(snapshot, now) +} + +func (s *OpenAIQuotaAutoResetService) persistFreshUsage(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage, now time.Time) error { + updates := buildOpenAIAutoResetUsageUpdates(usage, now) + if len(updates) > 0 { + if err := s.accountRepo.UpdateExtra(ctx, accountID, updates); err != nil { + return err + } + } + return s.quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits) +} + +func selectOpenAIAutoResetCandidate(candidates []openAIAutoResetCreditCandidate, available int, previous *OpenAIAutoResetCreditState, cycleHash string) (openAIAutoResetCreditCandidate, error) { + if available <= 0 { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_NO_CREDIT", "no reset credit is available") + } + if len(candidates) < available { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", "reset credit details are incomplete") + } + for _, candidate := range candidates { + if _, err := time.Parse(time.RFC3339, candidate.ExpiresAt); err != nil { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_EXPIRY_INVALID", "reset credit expiration is invalid") + } + } + sorted := append([]openAIAutoResetCreditCandidate(nil), candidates...) + sort.SliceStable(sorted, func(i, j int) bool { + left, leftErr := time.Parse(time.RFC3339, sorted[i].ExpiresAt) + right, rightErr := time.Parse(time.RFC3339, sorted[j].ExpiresAt) + if leftErr != nil { + return false + } + if rightErr != nil { + return true + } + return left.Before(right) + }) + if previous != nil && previous.AttemptCycleHash == cycleHash && previous.AttemptCreditHash != "" { + for _, candidate := range sorted { + if shortOpenAIAutoResetHash(candidate.ID) == previous.AttemptCreditHash { + if strings.TrimSpace(candidate.ID) == "" { + break + } + return candidate, nil + } + } + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_ORIGINAL_CREDIT_UNAVAILABLE", "the original reset credit cannot be confirmed; refusing to switch credits") + } + if len(sorted) == 0 || strings.TrimSpace(sorted[0].ID) == "" { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_ID_MISSING", "the earliest reset credit has no official id") + } + return sorted[0], nil +} + +func openAIAutoResetCycleSeed(usage *OpenAIQuotaUsage) string { + if usage == nil || usage.RateLimit == nil { + return "5h:0|7d:0" + } + var fiveHour, sevenDay int64 + for _, window := range []*OpenAIRateLimitWindow{usage.RateLimit.PrimaryWindow, usage.RateLimit.SecondaryWindow} { + if window == nil { + continue + } + resetAt := window.ResetAt + if resetAt <= 0 { + resetAt = usage.FetchedAt + window.ResetAfterSeconds + } + if window.LimitWindowSeconds <= 6*60*60 { + fiveHour = resetAt + } else { + sevenDay = resetAt + } + } + return fmt.Sprintf("5h:%d|7d:%d", fiveHour, sevenDay) +} + +func shortOpenAIAutoResetHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:12]) +} + +func openAIAutoResetSnapshotStale(extra map[string]any, now time.Time) bool { + if len(extra) == 0 { + return true + } + raw, ok := extra["codex_usage_updated_at"] + if !ok { + return true + } + updatedAt, err := parseTime(fmt.Sprint(raw)) + return err != nil || now.Sub(updatedAt) >= openAIAutoResetSnapshotTTL +} + +func openAIAutoResetStateFromExtra(extra map[string]any) *OpenAIAutoResetCreditState { + if len(extra) == 0 { + return nil + } + raw, ok := extra[OpenAIAutoResetCreditStateExtraKey] + if !ok || raw == nil { + return nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil + } + var state OpenAIAutoResetCreditState + if err := json.Unmarshal(encoded, &state); err != nil || state.Status == "" { + return nil + } + return &state +} + +func openAIAutoResetStateStale(state *OpenAIAutoResetCreditState, now time.Time) bool { + if state == nil || state.CheckedAt == "" { + return true + } + checkedAt, err := time.Parse(time.RFC3339, state.CheckedAt) + return err != nil || now.Sub(checkedAt) >= openAIAutoResetSnapshotTTL +} + +func stateAvailableCount(state *OpenAIAutoResetCreditState) int { + if state == nil { + return 0 + } + return state.AvailableCount +} + +func copyOpenAIAutoResetAttempt(target, source *OpenAIAutoResetCreditState) { + if target == nil || source == nil { + return + } + target.AttemptCycleHash = source.AttemptCycleHash + target.AttemptCreditHash = source.AttemptCreditHash +} + +func (s *OpenAIQuotaAutoResetService) persistState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState) error { + if state == nil { + return nil + } + return s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{OpenAIAutoResetCreditStateExtraKey: state}) +} + +func (s *OpenAIQuotaAutoResetService) failState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState, code string, cause error) error { + if state == nil { + state = &OpenAIAutoResetCreditState{} + } + if strings.TrimSpace(code) == "" { + code = "OPENAI_AUTO_RESET_FAILED" + } + state.Status = OpenAIAutoResetStatusFailed + state.ErrorCode = code + state.LastResultAt = time.Now().UTC().Format(time.RFC3339) + if err := s.persistState(ctx, accountID, state); err != nil { + return err + } + slog.Warn("openai_auto_reset_credit_failed", + "account_id", accountID, + "trigger_window", state.TriggerWindow, + "available_count", state.AvailableCount, + "error_code", code, + ) + if cause != nil { + return cause + } + return infraerrors.Conflict(code, "automatic reset credit operation failed") +} + +func (s *OpenAIQuotaAutoResetService) recordAudit(accountID int64, assessment openAIAutoResetAssessment, available int, resultCode string, windowsReset int, errorCode string) { + if s.audit == nil { + return + } + statusCode := http.StatusOK + if resultCode != "success" { + statusCode = http.StatusConflict + } + s.audit.Record(&AuditLog{ + ActorEmail: "system", + ActorRole: "system", + AuthMethod: "system", + Action: "system.openai.reset_credit.auto", + Method: "SYSTEM", + Path: fmt.Sprintf("/system/openai/accounts/%d/auto-reset-credit", accountID), + StatusCode: statusCode, + Extra: map[string]any{ + "account_id": accountID, + "trigger_window": assessment.triggerWindow, + "threshold_5h": assessment.threshold5h, + "threshold_7d": assessment.threshold7d, + "utilization_5h": assessment.utilization5h, + "utilization_7d": assessment.utilization7d, + "available_count": available, + "result_code": resultCode, + "windows_reset": windowsReset, + "error_code": errorCode, + }, + }) +} + +var openAIAutoResetNotifierRegistry struct { + sync.RWMutex + service *OpenAIQuotaAutoResetService +} + +func setOpenAIAutoResetNotifier(service *OpenAIQuotaAutoResetService) { + openAIAutoResetNotifierRegistry.Lock() + openAIAutoResetNotifierRegistry.service = service + openAIAutoResetNotifierRegistry.Unlock() +} + +func clearOpenAIAutoResetNotifier(service *OpenAIQuotaAutoResetService) { + openAIAutoResetNotifierRegistry.Lock() + if openAIAutoResetNotifierRegistry.service == service { + openAIAutoResetNotifierRegistry.service = nil + } + openAIAutoResetNotifierRegistry.Unlock() +} + +func notifyOpenAIAutoReset(accountID int64) { + openAIAutoResetNotifierRegistry.RLock() + service := openAIAutoResetNotifierRegistry.service + openAIAutoResetNotifierRegistry.RUnlock() + if service != nil { + service.Notify(accountID) + } +} + +// NotifyOpenAIAutoResetCredit 供额度查询入口发送轻量信号;不执行同步上游请求。 +func NotifyOpenAIAutoResetCredit(accountID int64) { + notifyOpenAIAutoReset(accountID) +} diff --git a/backend/internal/service/openai_quota_auto_reset_config.go b/backend/internal/service/openai_quota_auto_reset_config.go new file mode 100644 index 0000000000..e891049795 --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset_config.go @@ -0,0 +1,148 @@ +package service + +import ( + "encoding/json" + "math" + "net/http" + "strconv" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + OpenAIAutoResetCreditEnabledExtraKey = "auto_reset_credit_enabled" + OpenAIAutoResetCredit5hThresholdExtraKey = "auto_reset_credit_5h_threshold" + OpenAIAutoResetCredit7dThresholdExtraKey = "auto_reset_credit_7d_threshold" + OpenAIAutoResetCreditStateExtraKey = "codex_auto_reset_credit_state" + + openAIAutoResetCreditDefaultThreshold = 1.0 + openAIAutoResetCreditMinimumThreshold = 0.001 +) + +// OpenAIAutoResetCreditConfig 是账号级自动用卡配置。阈值采用 0-1 比例, +// 避免后端调度与前端百分比展示混用同一数值语义。 +type OpenAIAutoResetCreditConfig struct { + Enabled bool + Threshold5h float64 + Threshold7d float64 +} + +// ResolveOpenAIAutoResetCreditConfig 只接受 OpenAI OAuth 母账号;历史账号未配置时 +// 始终保持关闭,防止升级后产生意外消费。 +func ResolveOpenAIAutoResetCreditConfig(account *Account) OpenAIAutoResetCreditConfig { + config := OpenAIAutoResetCreditConfig{ + Threshold5h: openAIAutoResetCreditDefaultThreshold, + Threshold7d: openAIAutoResetCreditDefaultThreshold, + } + if !isOpenAIAutoResetCreditAccount(account) || account.Extra == nil { + return config + } + config.Enabled = resolveAccountExtraBool(account.Extra, OpenAIAutoResetCreditEnabledExtraKey) + if value, ok := resolveAccountExtraNumber(account.Extra, OpenAIAutoResetCredit5hThresholdExtraKey); ok && isValidOpenAIAutoResetThreshold(value) { + config.Threshold5h = value + } + if value, ok := resolveAccountExtraNumber(account.Extra, OpenAIAutoResetCredit7dThresholdExtraKey); ok && isValidOpenAIAutoResetThreshold(value) { + config.Threshold7d = value + } + return config +} + +func isOpenAIAutoResetCreditAccount(account *Account) bool { + return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth && !account.IsShadow() +} + +// normalizeOpenAIAutoResetCreditExtra 校验管理请求中的配置并剥离服务运行态。 +// enabled=true 时补齐两个 100% 默认阈值;关闭时保留已设置阈值,方便再次开启。 +func normalizeOpenAIAutoResetCreditExtra(platform, accountType string, isShadow bool, extra map[string]any) (map[string]any, error) { + if extra == nil { + return nil, nil + } + normalized := cloneOpenAIAutoResetExtra(extra) + delete(normalized, OpenAIAutoResetCreditStateExtraKey) + + _, hasEnabled := normalized[OpenAIAutoResetCreditEnabledExtraKey] + _, has5h := normalized[OpenAIAutoResetCredit5hThresholdExtraKey] + _, has7d := normalized[OpenAIAutoResetCredit7dThresholdExtraKey] + if !hasEnabled && !has5h && !has7d { + return normalized, nil + } + if platform != PlatformOpenAI || accountType != AccountTypeOAuth || isShadow { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_ACCOUNT_INVALID", "automatic reset credits are only supported for OpenAI OAuth parent accounts") + } + + enabled := false + if hasEnabled { + value, ok := normalized[OpenAIAutoResetCreditEnabledExtraKey].(bool) + if !ok { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_ENABLED_INVALID", "auto_reset_credit_enabled must be a boolean") + } + enabled = value + } + for key, present := range map[string]bool{ + OpenAIAutoResetCredit5hThresholdExtraKey: has5h, + OpenAIAutoResetCredit7dThresholdExtraKey: has7d, + } { + if !present { + if enabled { + normalized[key] = openAIAutoResetCreditDefaultThreshold + } + continue + } + value, ok := parseOpenAIAutoResetThreshold(normalized[key]) + if !ok || !isValidOpenAIAutoResetThreshold(value) { + return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_THRESHOLD_INVALID", "%s must be between 0.001 and 1.0", key) + } + normalized[key] = value + } + return normalized, nil +} + +func stripOpenAIAutoResetCreditManagedExtra(extra map[string]any, stripConfig bool) map[string]any { + if extra == nil { + return nil + } + delete(extra, OpenAIAutoResetCreditStateExtraKey) + if stripConfig { + delete(extra, OpenAIAutoResetCreditEnabledExtraKey) + delete(extra, OpenAIAutoResetCredit5hThresholdExtraKey) + delete(extra, OpenAIAutoResetCredit7dThresholdExtraKey) + } + return extra +} + +func parseOpenAIAutoResetThreshold(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func isValidOpenAIAutoResetThreshold(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= openAIAutoResetCreditMinimumThreshold && value <= 1 +} + +func cloneOpenAIAutoResetExtra(source map[string]any) map[string]any { + if source == nil { + return nil + } + cloned := make(map[string]any, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} diff --git a/backend/internal/service/openai_quota_auto_reset_test.go b/backend/internal/service/openai_quota_auto_reset_test.go new file mode 100644 index 0000000000..ad2dc965aa --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset_test.go @@ -0,0 +1,332 @@ +package service + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeOpenAIAutoResetCreditExtra(t *testing.T) { + t.Run("历史账号默认关闭", func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + config := ResolveOpenAIAutoResetCreditConfig(account) + require.False(t, config.Enabled) + require.Equal(t, 1.0, config.Threshold5h) + require.Equal(t, 1.0, config.Threshold7d) + }) + + t.Run("开启时补齐两个百分百阈值并剥离运行态", func(t *testing.T) { + extra, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCreditStateExtraKey: map[string]any{"status": "success"}, + }) + require.NoError(t, err) + require.Equal(t, 1.0, extra[OpenAIAutoResetCredit5hThresholdExtraKey]) + require.Equal(t, 1.0, extra[OpenAIAutoResetCredit7dThresholdExtraKey]) + require.NotContains(t, extra, OpenAIAutoResetCreditStateExtraKey) + }) + + t.Run("阈值和账号类型严格校验", func(t *testing.T) { + _, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 0.0009, + }) + require.Error(t, err) + + _, err = normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, true, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + }) + require.Error(t, err) + }) +} + +func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T) { + now := time.Now().UTC() + baseExtra := map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "auto_pause_5h_threshold": 0.8, + "auto_pause_7d_disabled": true, + "codex_5h_used_percent": 90.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + } + + t.Run("卡状态未知时暂停并触发异步查询", func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: cloneOpenAIAutoResetExtra(baseExtra)} + paused, decision := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.True(t, paused) + require.Equal(t, "quota_auto_reset_credit_check_5h", decision.reason) + }) + + t.Run("明确有卡时允许继续到用卡阈值", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, _ := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.False(t, paused) + }) + + t.Run("达到用卡阈值后即使有卡也退出调度", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra["codex_5h_used_percent"] = 100.0 + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, decision := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.True(t, paused) + require.Equal(t, "quota_auto_reset_pending_5h", decision.reason) + }) + + t.Run("自然窗口重置后清除动态阻塞", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra["codex_5h_used_percent"] = 100.0 + extra["codex_5h_reset_at"] = now.Add(-time.Second).Format(time.RFC3339) + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusFailed, TriggerWindow: "5h", ErrorCode: "RESET_FAILED", + } + account := &Account{ID: 4, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, _ := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.False(t, paused) + }) +} + +func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) { + candidates := []openAIAutoResetCreditCandidate{ + {ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"}, + {ID: "earlier", ExpiresAt: "2026-09-01T00:00:00Z"}, + } + selected, err := selectOpenAIAutoResetCandidate(candidates, 2, nil, "cycle-a") + require.NoError(t, err) + require.Equal(t, "earlier", selected.ID) + + _, err = selectOpenAIAutoResetCandidate([]openAIAutoResetCreditCandidate{ + {ExpiresAt: "2026-09-01T00:00:00Z"}, + }, 1, nil, "cycle-a") + require.Error(t, err) + + _, err = selectOpenAIAutoResetCandidate(candidates, 2, &OpenAIAutoResetCreditState{ + AttemptCycleHash: "cycle-a", AttemptCreditHash: shortOpenAIAutoResetHash("missing"), + }, "cycle-a") + require.Error(t, err, "模糊结果后原卡消失时不得切换下一张卡") +} + +func TestOpenAIQuotaAutoResetService_AssessesIndependentWindows(t *testing.T) { + service := &OpenAIQuotaAutoResetService{} + account := &Account{Extra: map[string]any{ + "auto_pause_5h_disabled": true, + "auto_pause_7d_disabled": true, + }} + config := OpenAIAutoResetCreditConfig{Enabled: true, Threshold5h: 0.8, Threshold7d: 0.9} + tests := []struct { + name string + fiveHour float64 + sevenDay float64 + wantWindow string + }{ + {name: "5h", fiveHour: 0.8, sevenDay: 0.2, wantWindow: "5h"}, + {name: "7d", fiveHour: 0.2, sevenDay: 0.9, wantWindow: "7d"}, + {name: "同时触发", fiveHour: 0.95, sevenDay: 0.95, wantWindow: "5h+7d"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assessment := service.buildAssessment(account, config, test.fiveHour, test.sevenDay) + require.True(t, assessment.resetReached) + require.Equal(t, test.wantWindow, assessment.triggerWindow) + }) + } +} + +type autoResetTestAccountRepo struct { + AccountRepository + mu sync.Mutex + account *Account +} + +func (r *autoResetTestAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + copy := *r.account + copy.Extra = cloneOpenAIAutoResetExtra(r.account.Extra) + return ©, nil +} + +func (r *autoResetTestAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.account.Extra == nil { + r.account.Extra = make(map[string]any) + } + for key, value := range updates { + r.account.Extra[key] = value + } + return nil +} + +type autoResetTestQuota struct { + usage *OpenAIQuotaUsage + resetCalls atomic.Int32 + resetEntered chan struct{} + releaseReset chan struct{} + enterOnce sync.Once + mu sync.Mutex + resetArgs [][2]string + failFirst bool +} + +func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) { + copy := *q.usage + return ©, nil +} + +func (q *autoResetTestQuota) CacheResetCreditsSnapshot(context.Context, int64, *OpenAIRateLimitResetCredits) error { + return nil +} + +func (q *autoResetTestQuota) ResetCreditTargeted(_ context.Context, _ int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { + if creditID == "" || redeemRequestID == "" { + panic("targeted reset identifiers must be present") + } + call := q.resetCalls.Add(1) + q.mu.Lock() + q.resetArgs = append(q.resetArgs, [2]string{creditID, redeemRequestID}) + q.mu.Unlock() + if q.failFirst && call == 1 { + return nil, context.DeadlineExceeded + } + if q.resetEntered != nil { + q.enterOnce.Do(func() { close(q.resetEntered) }) + } + if q.releaseReset != nil { + <-q.releaseReset + } + return &OpenAIQuotaResetResult{Code: "ok", WindowsReset: 2}, nil +} + +type autoResetTestRecoverer struct{} + +func (autoResetTestRecoverer) RecoverAccountState(context.Context, int64, AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) { + return &SuccessfulTestRecoveryResult{ClearedRateLimit: true}, nil +} + +func TestOpenAIQuotaAutoResetService_ConcurrentInstancesConsumeOnce(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 99, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 100.0, + "codex_7d_used_percent": 10.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + "codex_7d_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + usage := &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 100, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix()}, + SecondaryWindow: &OpenAIRateLimitWindow{UsedPercent: 10, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 86400, ResetAt: now.Add(24 * time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + }, + autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "credit-sensitive-id", ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + } + quota := &autoResetTestQuota{usage: usage, resetEntered: make(chan struct{}), releaseReset: make(chan struct{})} + idempotencyRepo := newInMemoryIdempotencyRepo() + config := DefaultIdempotencyConfig() + config.ObserveOnly = false + config.ProcessingTimeout = time.Second + serviceA := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(idempotencyRepo, config), nil, nil, nil) + serviceB := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(idempotencyRepo, config), nil, nil, nil) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = serviceA.evaluateAccount(context.Background(), account.ID) + }() + <-quota.resetEntered + go func() { + defer wg.Done() + _ = serviceB.evaluateAccount(context.Background(), account.ID) + }() + time.Sleep(50 * time.Millisecond) + close(quota.releaseReset) + wg.Wait() + + require.Equal(t, int32(1), quota.resetCalls.Load()) + repo.mu.Lock() + state := openAIAutoResetStateFromExtra(repo.account.Extra) + repo.mu.Unlock() + require.NotNil(t, state) + require.Equal(t, OpenAIAutoResetStatusSuccess, state.Status) + encodedState, err := json.Marshal(state) + require.NoError(t, err) + require.NotContains(t, string(encodedState), "credit-sensitive-id") +} + +func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 100.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + expiresAt := now.Add(48 * time.Hour).Format(time.RFC3339) + quota := &autoResetTestQuota{ + failFirst: true, + usage: &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 100, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: expiresAt}}, + }, + autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "retry-credit", ExpiresAt: expiresAt}}, + }, + } + idempotencyConfig := DefaultIdempotencyConfig() + idempotencyConfig.ObserveOnly = false + idempotencyConfig.FailedRetryBackoff = 0 + service := NewOpenAIQuotaAutoResetService( + repo, + quota, + autoResetTestRecoverer{}, + NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), + nil, nil, nil, + ) + + require.Error(t, service.evaluateAccount(context.Background(), account.ID)) + require.NoError(t, service.evaluateAccount(context.Background(), account.ID)) + quota.mu.Lock() + args := append([][2]string(nil), quota.resetArgs...) + quota.mu.Unlock() + require.Len(t, args, 2) + require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id") +} diff --git a/backend/internal/service/openai_quota_reset_credits.go b/backend/internal/service/openai_quota_reset_credits.go index 9ff7e70f16..b87e3fd0d1 100644 --- a/backend/internal/service/openai_quota_reset_credits.go +++ b/backend/internal/service/openai_quota_reset_credits.go @@ -8,6 +8,9 @@ import ( ) type openAIRateLimitResetCreditDetailPayload struct { + ID string `json:"id,omitempty"` + CreditID string `json:"credit_id,omitempty"` + CreditIDCamel string `json:"creditId,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` ExpiresAtCamel string `json:"expiresAt,omitempty"` ResetType string `json:"reset_type,omitempty"` @@ -29,6 +32,14 @@ type openAIRateLimitResetCreditDetails struct { AvailableCreditCount int CreditListPresent bool Credits []OpenAIRateLimitResetCreditDetail + AutoResetCandidates []openAIAutoResetCreditCandidate +} + +// openAIAutoResetCreditCandidate 仅在服务内部流转。上游卡 ID 不进入 API DTO、 +// 账号 extra 或日志,避免管理端响应扩大敏感标识暴露面。 +type openAIAutoResetCreditCandidate struct { + ID string + ExpiresAt string } func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCreditDetails, error) { @@ -64,6 +75,7 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr } credits := make([]OpenAIRateLimitResetCreditDetail, 0, len(rawCredits)) + autoResetCandidates := make([]openAIAutoResetCreditCandidate, 0, len(rawCredits)) availableCreditCount := 0 for _, raw := range rawCredits { if raw == nil { @@ -88,12 +100,24 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr continue } credits = append(credits, OpenAIRateLimitResetCreditDetail{ExpiresAt: expiresAt}) + creditID := strings.TrimSpace(raw.ID) + if creditID == "" { + creditID = strings.TrimSpace(raw.CreditID) + } + if creditID == "" { + creditID = strings.TrimSpace(raw.CreditIDCamel) + } + autoResetCandidates = append(autoResetCandidates, openAIAutoResetCreditCandidate{ + ID: creditID, + ExpiresAt: expiresAt, + }) } return openAIRateLimitResetCreditDetails{ AvailableCount: availableCount, AvailableCreditCount: availableCreditCount, CreditListPresent: creditListPresent, Credits: credits, + AutoResetCandidates: autoResetCandidates, }, nil } diff --git a/backend/internal/service/openai_quota_reset_credits_test.go b/backend/internal/service/openai_quota_reset_credits_test.go index 34f87f9a2f..2deb01ed89 100644 --- a/backend/internal/service/openai_quota_reset_credits_test.go +++ b/backend/internal/service/openai_quota_reset_credits_test.go @@ -14,8 +14,8 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t "availableCount":"2", "credits":[ {"reset_type":"codex_rate_limits","status":"redeemed","expires_at":"2026-07-01T04:05:06Z"}, - {"reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"}, - {"resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"}, + {"id":"credit-later","reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"}, + {"creditId":"credit-earlier","resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"}, {"reset_type":"other","status":"available","expires_at":"2026-07-02T04:05:06Z"} ] }`) @@ -28,6 +28,10 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t {ExpiresAt: "2026-07-04T04:05:06Z"}, {ExpiresAt: "2026-07-03T04:05:06Z"}, }, details.Credits) + require.Equal(t, []openAIAutoResetCreditCandidate{ + {ID: "credit-later", ExpiresAt: "2026-07-04T04:05:06Z"}, + {ID: "credit-earlier", ExpiresAt: "2026-07-03T04:05:06Z"}, + }, details.AutoResetCandidates) } func TestQueryUsageResetCreditCountPrecedence(t *testing.T) { diff --git a/backend/internal/service/openai_quota_reset_workflow.go b/backend/internal/service/openai_quota_reset_workflow.go new file mode 100644 index 0000000000..430b29618b --- /dev/null +++ b/backend/internal/service/openai_quota_reset_workflow.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "log/slog" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + OpenAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" + OpenAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" + OpenAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" +) + +type openAIQuotaResetWorkflowQuota interface { + QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error +} + +type openAIQuotaResetWorkflowRecoverer interface { + RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) +} + +// OpenAIQuotaResetPostProcessResult 汇总手动和自动用卡后的共享恢复结果。 +// WarningCode 允许上游消费成功但本地恢复部分失败时仍然准确呈现状态。 +type OpenAIQuotaResetPostProcessResult struct { + Quota *OpenAIQuotaUsage + Account *Account + CacheRefreshed bool + AccountStateRecovered bool + WarningCode string +} + +// RunOpenAIQuotaResetPostProcess 按“解除限流、刷新额度缓存、刷新账号行”的固定顺序 +// 执行消费后恢复,避免手动和自动入口在失败语义上逐渐分叉。 +func RunOpenAIQuotaResetPostProcess( + ctx context.Context, + accountID int64, + quota openAIQuotaResetWorkflowQuota, + recoverer openAIQuotaResetWorkflowRecoverer, + loadAccount func(context.Context, int64) (*Account, error), +) OpenAIQuotaResetPostProcessResult { + result := OpenAIQuotaResetPostProcessResult{} + if recoverer == nil { + result.WarningCode = OpenAIQuotaResetWarningAccountRecoveryFailed + return result + } + if _, err := recoverer.RecoverAccountState(ctx, accountID, AccountRecoveryOptions{InvalidateToken: true}); err != nil { + slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + result.WarningCode = OpenAIQuotaResetWarningAccountRecoveryFailed + return result + } + result.AccountStateRecovered = true + + if quota != nil { + usage, usageErr := quota.QueryUsage(ctx, accountID) + switch { + case usageErr != nil || usage == nil: + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(usageErr)) + result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed + default: + if err := quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed + } else { + result.Quota = usage + result.CacheRefreshed = true + } + } + } + + if loadAccount == nil { + return result + } + account, err := loadAccount(ctx, accountID) + if err != nil { + slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + if result.WarningCode == "" { + result.WarningCode = OpenAIQuotaResetWarningAccountRefreshFailed + } + return result + } + result.Account = account + return result +} diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 58bf4659f5..6e40c497fc 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -87,6 +87,7 @@ type OpenAIQuotaUsage struct { AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` FetchedAt int64 `json:"fetched_at"` + autoResetCandidates []openAIAutoResetCreditCandidate } // OpenAIQuotaResetCredit captures the redeemed credit metadata returned by the @@ -180,6 +181,10 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* continue } status := resp.StatusCode + if isOpenAIAutoResetContext(ctx) { + slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "source", "auto_reset") + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d", status) + } body := truncate(s.redactQuotaErrorBody(ctx, accountID, resp.String()), 240) slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "body", body) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) @@ -190,6 +195,7 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* payload.FetchedAt = time.Now().Unix() details := s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID) if details != nil { + payload.autoResetCandidates = details.AutoResetCandidates hasDetailCount := details.AvailableCount != nil if payload.RateLimitResetCredits == nil { payload.RateLimitResetCredits = &OpenAIRateLimitResetCredits{} @@ -274,6 +280,25 @@ func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client // The redeem_request_id is auto-generated (uuid-like) — upstream uses it for // idempotency. Returns the consumed credit metadata so the UI can refresh. func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) { + redeemRequestID, err := generateRedeemRequestID() + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) + } + return s.resetCredit(ctx, accountID, "", redeemRequestID, false) +} + +// ResetCreditTargeted 使用固定卡 ID 与兑换 ID执行自动消费。调用方必须在重试时 +// 复用同一组参数;本方法不会回退到不带 credit_id 的旧消费方式。 +func (s *OpenAIQuotaService) ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { + creditID = strings.TrimSpace(creditID) + redeemRequestID = strings.TrimSpace(redeemRequestID) + if creditID == "" || redeemRequestID == "" { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_TARGETED_RESET_INVALID", "credit_id and redeem_request_id are required") + } + return s.resetCredit(ctx, accountID, creditID, redeemRequestID, true) +} + +func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string, targeted bool) (*OpenAIQuotaResetResult, error) { // Shadow guard: resetting credits via a shadow account would silently // operate on the parent's quota; that is surprising and unwanted. Callers // must reset the parent account directly. @@ -297,11 +322,6 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( return nil, err } - redeemRequestID, err := generateRedeemRequestID() - if err != nil { - return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) - } - client, err := s.privacyClientFactory(proxyURL) if err != nil { return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err) @@ -318,10 +338,14 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr) } headers["content-type"] = "application/json" + body := map[string]string{"redeem_request_id": redeemRequestID} + if targeted { + body["credit_id"] = creditID + } resp, err := client.R(). SetContext(callCtx). SetHeaders(headers). - SetBody(map[string]string{"redeem_request_id": redeemRequestID}). + SetBody(body). SetSuccessResult(&payload). Post(chatGPTRateLimitResetURL) if err != nil { @@ -336,6 +360,10 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( continue } status := resp.StatusCode + if targeted { + slog.Warn("openai_quota_targeted_reset_failed", "account_id", accountID, "status", status) + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d", status) + } body := truncate(s.redactQuotaErrorBody(callCtx, accountID, resp.String()), 240) slog.Warn("openai_quota_reset_failed", "account_id", accountID, "status", status, "body", body) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index a351381cb4..cb47a0c0c5 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -189,6 +189,32 @@ func TestResetCreditShadowRejected(t *testing.T) { "shadow ResetCredit 应映射为 409 Conflict 而非 500") } +func TestResetCreditTargetedSendsStableCreditAndRedeemIDs(t *testing.T) { + account := &Account{ + ID: 203, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "account-targeted"}, + } + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}} + tokenCache := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(account): "fake-token"}} + tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil) + var body map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/backend-api/wham/rate-limit-reset-credits/consume", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"code":"ok","windows_reset":1}`)) + })) + defer srv.Close() + + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + result, err := svc.ResetCreditTargeted(context.Background(), account.ID, "credit-123", "redeem-456") + require.NoError(t, err) + require.Equal(t, "ok", result.Code) + require.Equal(t, map[string]string{ + "credit_id": "credit-123", "redeem_request_id": "redeem-456", + }, body) +} + func TestResetCreditAgentIdentityUsesAssertionAndRecoversInvalidTaskOnce(t *testing.T) { _, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) diff --git a/backend/internal/service/openai_response_terminal_usage_compat_test.go b/backend/internal/service/openai_response_terminal_usage_compat_test.go new file mode 100644 index 0000000000..5a24f0addb --- /dev/null +++ b/backend/internal/service/openai_response_terminal_usage_compat_test.go @@ -0,0 +1,115 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestEffectiveOpenAISSEEventTypePrefersPayload(t *testing.T) { + t.Parallel() + + require.Equal(t, "response.failed", effectiveOpenAISSEEventType([]byte(`{"type":"response.failed"}`), "error")) + require.Equal(t, "error", effectiveOpenAISSEEventType([]byte(`{"error":{"message":"failed"}}`), " error ")) + require.JSONEq(t, `{"type":"error","error":{"message":"failed"}}`, openAICompatPayloadWithEventType(`{"type":"","error":{"message":"failed"}}`, "error")) +} + +func TestExtractOpenAISSETerminalEventUsesEventField(t *testing.T) { + t.Parallel() + + body := "event: error\n" + + "data: {\"error\":{\"message\":\"provider failed\"}}\n\n" + + "data: [DONE]\n\n" + eventType, payload, ok := extractOpenAISSETerminalEvent(body) + require.True(t, ok) + require.Equal(t, "error", eventType) + require.Equal(t, "provider failed", extractOpenAISSEErrorMessage(payload)) +} + +func TestExtractOpenAISSETerminalEventUsesFinalAuthoritativeTerminal(t *testing.T) { + t.Parallel() + + body := "data: {\"type\":\"error\",\"error\":{\"message\":\"recovering\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"status\":\"completed\"}}\n\n" + eventType, payload, ok := extractOpenAISSETerminalEvent(body) + require.True(t, ok) + require.Equal(t, "response.completed", eventType) + require.Equal(t, "resp_1", gjson.GetBytes(payload, "response.id").String()) +} + +func TestParseSSEUsageEffectiveTerminalRules(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + usage := &OpenAIUsage{} + svc.parseSSEUsageBytesWithType([]byte(`{"usage":{"input_tokens":17,"output_tokens":5,"input_tokens_details":{"cached_tokens":3}}}`), "response.in_progress", usage) + svc.parseSSEUsageBytesWithType([]byte(`{"response":{"id":"resp_1"}}`), "response.completed", usage) + require.Equal(t, OpenAIUsage{InputTokens: 17, OutputTokens: 5, CacheReadInputTokens: 3}, *usage) + + svc.parseSSEUsageBytesWithType([]byte(`{"response":{"usage":{"input_tokens":0,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), "response.completed", usage) + require.Equal(t, OpenAIUsage{InputTokens: 17, OutputTokens: 5, CacheReadInputTokens: 3}, *usage) + + svc.parseSSEUsageBytesWithType([]byte(`{"response":{"usage":{"input_tokens":2,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), "response.completed", usage) + require.Equal(t, OpenAIUsage{InputTokens: 2}, *usage) +} + +func BenchmarkParseSSEUsageNoUsageDelta(b *testing.B) { + svc := &OpenAIGatewayService{} + usage := &OpenAIUsage{} + payload := []byte(`{"type":"response.output_text.delta","delta":"hello"}`) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + svc.parseSSEUsageBytesWithType(payload, "response.output_text.delta", usage) + } +} + +func TestOpenAICompatTerminalResponseSynthesizesBareError(t *testing.T) { + t.Parallel() + + payload := []byte(`{"type":"error","code":"upstream_error","error":{"message":"provider failed"}}`) + event := &apicompat.ResponsesStreamEvent{Type: "error", Code: "upstream_error"} + response := openAICompatTerminalResponse(event, payload) + require.NotNil(t, response) + require.Equal(t, "failed", response.Status) + require.Equal(t, "upstream_error", response.Error.Code) + require.Equal(t, "provider failed", response.Error.Message) +} + +func TestForEachOpenAISSEFrameDataTypeOverridesEventField(t *testing.T) { + t.Parallel() + + var types []string + forEachOpenAISSEFrame(strings.Join([]string{ + "event: response.in_progress", + `data: {"type":"response.completed","response":{"id":"resp_1"}}`, + "", + }, "\n"), func(eventType string, _ []byte) { + types = append(types, eventType) + }) + require.Equal(t, []string{"response.completed"}, types) +} + +func TestSampleOpenAIMissingUsageLogRateLimitsAndCounts(t *testing.T) { + var sampler openAIMissingUsageLogSampler + now := time.Unix(100, 0) + logNow, total, suppressed := sampler.sample(now) + require.True(t, logNow) + require.Equal(t, uint64(1), total) + require.Zero(t, suppressed) + + logNow, total, _ = sampler.sample(now.Add(time.Second)) + require.False(t, logNow) + require.Equal(t, uint64(2), total) + + logNow, total, suppressed = sampler.sample(now.Add(openAIMissingUsageLogInterval)) + require.True(t, logNow) + require.Equal(t, uint64(3), total) + require.Equal(t, uint64(1), suppressed) +} diff --git a/backend/internal/service/openai_responses_ingress_compat.go b/backend/internal/service/openai_responses_ingress_compat.go new file mode 100644 index 0000000000..f3162b3b02 --- /dev/null +++ b/backend/internal/service/openai_responses_ingress_compat.go @@ -0,0 +1,205 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +// normalizeOpenAIResponsesLegacyIngress accepts the Chat Completions-shaped +// payloads that a few Responses clients still emit. Native Responses input is +// always authoritative because this path has no separate full-replay attempt. +func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + var request map[string]any + if err := decodeOpenAIJSONUseNumber(body, &request); err != nil { + return body, false, fmt.Errorf("normalize legacy Responses ingress: %w", err) + } + + changed := false + messagesValue, hasMessages := request["messages"] + if hasMessages { + messages, messagesAreArray := messagesValue.([]any) + if messagesAreArray && len(messages) > 0 { + legacy, err := convertLegacyResponsesMessages(body) + if err != nil { + return body, false, err + } + + input, hasInput := request["input"] + hasNativeInput := hasInput && input != nil + if !hasNativeInput { + request["input"] = legacy.input + applyLegacyResponsesTopLevelFields(request, legacy) + delete(request, "previous_response_id") + } + } + // messages is not a Responses field. When native input is present but the + // two histories are ambiguous, retain input and drop only the legacy alias. + delete(request, "messages") + changed = true + } + + if prompt, hasPrompt := request["prompt"]; hasPrompt { + // Only the legacy string alias is unambiguously equivalent to Responses + // input. Objects are native reusable prompt templates and must remain in + // prompt; arrays and other shapes are left for upstream validation rather + // than being relabeled as a structurally different input value. + if promptText, isLegacyString := prompt.(string); isLegacyString { + if input, hasInput := request["input"]; !hasInput || input == nil { + request["input"] = promptText + } + delete(request, "prompt") + changed = true + } + } + if _, hasCommands := request["commands"]; hasCommands { + delete(request, "commands") + changed = true + } + + if !changed { + return body, false, nil + } + normalized, err := marshalOpenAIUpstreamJSON(request) + if err != nil { + return body, false, fmt.Errorf("serialize legacy Responses ingress: %w", err) + } + return normalized, true, nil +} + +type convertedLegacyResponsesMessages struct { + input any + instructions string + maxOutputTokens *int + temperature *float64 + topP *float64 + tools any + reasoning any + toolChoice any + serviceTier string + hasChatTools bool + hasChatToolChoice bool + hasReasoningEffort bool +} + +func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) { + var converted convertedLegacyResponsesMessages + var chatRequest apicompat.ChatCompletionsRequest + if err := json.Unmarshal(body, &chatRequest); err != nil { + return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) + } + responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest) + if err != nil { + return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) + } + if len(responsesRequest.Input) == 0 { + return converted, fmt.Errorf("normalize legacy Responses messages: empty input") + } + if err := json.Unmarshal(responsesRequest.Input, &converted.input); err != nil { + return converted, fmt.Errorf("decode normalized legacy Responses input: %w", err) + } + + converted.instructions = strings.TrimSpace(responsesRequest.Instructions) + converted.maxOutputTokens = responsesRequest.MaxOutputTokens + converted.temperature = responsesRequest.Temperature + converted.topP = responsesRequest.TopP + converted.serviceTier = strings.TrimSpace(responsesRequest.ServiceTier) + converted.hasReasoningEffort = strings.TrimSpace(chatRequest.ReasoningEffort) != "" + if responsesRequest.Reasoning != nil { + converted.reasoning = responsesRequest.Reasoning + } + + var rawRequest map[string]any + if err := decodeOpenAIJSONUseNumber(body, &rawRequest); err != nil { + return converted, fmt.Errorf("inspect legacy Responses fields: %w", err) + } + converted.hasChatTools = hasChatStyleTools(rawRequest) || rawRequest["functions"] != nil + if converted.hasChatTools && len(responsesRequest.Tools) > 0 { + converted.tools = responsesRequest.Tools + } + converted.hasChatToolChoice = hasChatStyleToolChoice(rawRequest) || rawRequest["function_call"] != nil + if converted.hasChatToolChoice && len(responsesRequest.ToolChoice) > 0 { + choice, err := normalizeLegacyResponsesToolChoice(responsesRequest.ToolChoice) + if err != nil { + return converted, err + } + converted.toolChoice = choice + } + return converted, nil +} + +func hasChatStyleTools(request map[string]any) bool { + tools, ok := request["tools"].([]any) + if !ok { + return false + } + for _, rawTool := range tools { + tool, _ := rawTool.(map[string]any) + if _, ok := tool["function"].(map[string]any); ok { + return true + } + } + return false +} + +func hasChatStyleToolChoice(request map[string]any) bool { + choice, _ := request["tool_choice"].(map[string]any) + _, ok := choice["function"].(map[string]any) + return ok +} + +func normalizeLegacyResponsesToolChoice(raw json.RawMessage) (any, error) { + var choice any + if err := json.Unmarshal(raw, &choice); err != nil { + return nil, fmt.Errorf("normalize legacy Responses tool_choice: %w", err) + } + object, ok := choice.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(object["type"])) != "function" { + return choice, nil + } + function, ok := object["function"].(map[string]any) + if !ok { + return choice, nil + } + name := strings.TrimSpace(firstNonEmptyString(function["name"])) + if name == "" { + return choice, nil + } + return map[string]any{"type": "function", "name": name}, nil +} + +func applyLegacyResponsesTopLevelFields(request map[string]any, legacy convertedLegacyResponsesMessages) { + if legacy.instructions != "" && strings.TrimSpace(firstNonEmptyString(request["instructions"])) == "" { + request["instructions"] = legacy.instructions + } + if legacy.maxOutputTokens != nil { + request["max_output_tokens"] = *legacy.maxOutputTokens + } + if legacy.temperature != nil { + request["temperature"] = *legacy.temperature + } + if legacy.topP != nil { + request["top_p"] = *legacy.topP + } + if legacy.hasChatTools && legacy.tools != nil { + request["tools"] = legacy.tools + } + if legacy.hasReasoningEffort && legacy.reasoning != nil { + request["reasoning"] = legacy.reasoning + } + if legacy.hasChatToolChoice && legacy.toolChoice != nil { + request["tool_choice"] = legacy.toolChoice + } + if legacy.serviceTier != "" { + request["service_tier"] = legacy.serviceTier + } + for _, key := range []string{"max_tokens", "max_completion_tokens", "reasoning_effort", "functions", "function_call"} { + delete(request, key) + } +} diff --git a/backend/internal/service/openai_responses_ingress_compat_test.go b/backend/internal/service/openai_responses_ingress_compat_test.go new file mode 100644 index 0000000000..acd2c202c0 --- /dev/null +++ b/backend/internal/service/openai_responses_ingress_compat_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestNormalizeOpenAIResponsesLegacyIngressMessagesOnly(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"system","content":"repo policy"},{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"hello\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}],"previous_response_id":"resp_stale"}`) + + normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "messages").Exists()) + require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) + require.Equal(t, "system", gjson.GetBytes(normalized, "input.0.role").String()) + require.Equal(t, "repo policy", gjson.GetBytes(normalized, "input.0.content").String()) + require.Equal(t, "function_call", gjson.GetBytes(normalized, "input.1.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(normalized, "input.1.call_id").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(normalized, "input.2.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(normalized, "input.2.call_id").String()) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "stream").Type) +} + +func TestNormalizeOpenAIResponsesLegacyIngressConvertsChatTopLevelFields(t *testing.T) { + body := []byte(`{"model":"gpt-4.1","messages":[{"role":"user","content":"weather?"}],"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup weather","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}},"max_tokens":64,"reasoning_effort":"high","service_tier":"flex","temperature":0.2,"top_p":0.7}`) + + normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "user", gjson.GetBytes(normalized, "input.0.role").String()) + require.Equal(t, "function", gjson.GetBytes(normalized, "tools.0.type").String()) + require.Equal(t, "lookup", gjson.GetBytes(normalized, "tools.0.name").String()) + require.False(t, gjson.GetBytes(normalized, "tools.0.function").Exists()) + require.Equal(t, "function", gjson.GetBytes(normalized, "tool_choice.type").String()) + require.Equal(t, "lookup", gjson.GetBytes(normalized, "tool_choice.name").String()) + require.False(t, gjson.GetBytes(normalized, "tool_choice.function").Exists()) + require.Equal(t, int64(128), gjson.GetBytes(normalized, "max_output_tokens").Int()) + require.Equal(t, "high", gjson.GetBytes(normalized, "reasoning.effort").String()) + require.Equal(t, "auto", gjson.GetBytes(normalized, "reasoning.summary").String()) + require.Equal(t, "flex", gjson.GetBytes(normalized, "service_tier").String()) + require.False(t, gjson.GetBytes(normalized, "max_tokens").Exists()) + require.False(t, gjson.GetBytes(normalized, "reasoning_effort").Exists()) +} + +func TestNormalizeOpenAIResponsesLegacyIngressNativeInputRemainsAuthoritative(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":[{"id":"msg_native","type":"message","role":"user","content":[{"type":"input_text","text":"keep me"}]}],"messages":[{"role":"user","content":"do not merge"}],"tools":[{"type":"function","function":{"name":"native_shape"}}],"tool_choice":{"type":"function","function":{"name":"native_shape"}},"previous_response_id":"resp_keep"}`) + + normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "messages").Exists()) + require.Equal(t, "msg_native", gjson.GetBytes(normalized, "input.0.id").String()) + require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int()) + require.Equal(t, "native_shape", gjson.GetBytes(normalized, "tools.0.function.name").String()) + require.False(t, gjson.GetBytes(normalized, "tools.0.name").Exists()) + require.Equal(t, "native_shape", gjson.GetBytes(normalized, "tool_choice.function.name").String()) + require.Equal(t, "resp_keep", gjson.GetBytes(normalized, "previous_response_id").String()) +} + +func TestNormalizeOpenAIResponsesLegacyIngressKeepsPromptAliasAndDropsCommands(t *testing.T) { + body := []byte(`{"model":"gpt-4.1","prompt":"hello","commands":[{"name":"legacy"}]}`) + + normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "hello", gjson.GetBytes(normalized, "input").String()) + require.False(t, gjson.GetBytes(normalized, "prompt").Exists()) + require.False(t, gjson.GetBytes(normalized, "commands").Exists()) +} + +func TestNormalizeOpenAIResponsesLegacyIngressPreservesNativePromptTemplate(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","prompt":{"id":"pmpt_abc","version":"7","variables":{"topic":"ownership"}}}`) + + normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body) + require.NoError(t, err) + require.False(t, changed) + require.JSONEq(t, string(body), string(normalized)) + require.Equal(t, "pmpt_abc", gjson.GetBytes(normalized, "prompt.id").String()) + require.False(t, gjson.GetBytes(normalized, "input").Exists()) +} + +func TestNormalizeOpenAIResponsesLegacyIngressPreservesUnknownPromptShapeWhileDroppingCommands(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","prompt":["one","two"],"commands":[{"name":"legacy"}]}`) + + normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, int64(2), gjson.GetBytes(normalized, "prompt.#").Int()) + require.False(t, gjson.GetBytes(normalized, "input").Exists()) + require.False(t, gjson.GetBytes(normalized, "commands").Exists()) +} diff --git a/backend/internal/service/openai_responses_input_compat.go b/backend/internal/service/openai_responses_input_compat.go new file mode 100644 index 0000000000..e8c4a8116d --- /dev/null +++ b/backend/internal/service/openai_responses_input_compat.go @@ -0,0 +1,76 @@ +package service + +import ( + "strings" +) + +const openAIResponsesInputTextMaxChars = 10000000 + +// sanitizeOpenAIResponsesOrphanToolOutputs removes tool-output items that have +// no matching call or item reference anywhere in the current input. +func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []any, hasPreviousResponseID bool) bool { + if len(input) == 0 || hasPreviousResponseID { + return false + } + + toolCallIDs := make(map[string]struct{}, len(input)) + referenceIDs := make(map[string]struct{}, len(input)) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + itemType := strings.TrimSpace(firstNonEmptyString(item["type"])) + if itemType == "item_reference" { + if id := strings.TrimSpace(firstNonEmptyString(item["id"])); id != "" { + referenceIDs[id] = struct{}{} + } + continue + } + if !isCodexToolCallContextItemType(itemType) { + continue + } + if id := strings.TrimSpace(firstNonEmptyString(item["call_id"], item["id"])); id != "" { + toolCallIDs[id] = struct{}{} + } + } + + modified := false + normalized := make([]any, 0, len(input)) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || !isCodexToolCallOutputItemType(strings.TrimSpace(firstNonEmptyString(item["type"]))) { + normalized = append(normalized, rawItem) + continue + } + + callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])) + _, hasToolCall := toolCallIDs[callID] + _, hasReference := referenceIDs[callID] + if callID != "" && (hasToolCall || hasReference) { + normalized = append(normalized, rawItem) + continue + } + + modified = true + } + if !modified { + return false + } + reqBody["input"] = normalized + return true +} + +func truncateOpenAIResponsesInputText(_ map[string]any) bool { + // Do not silently rewrite client or tool output. If an upstream enforces a + // text limit, forwarding the original value preserves its explicit error for + // the client and the normal Ops error pipeline. This compatibility shim is + // retained until the two callers can remove the old mutation hook together. + return false +} + +func openAIResponsesInputMayNeedTruncation(_ []byte) bool { + // See truncateOpenAIResponsesInputText. Returning false also avoids decoding + // very large bodies solely for a mutation that must not happen. + return false +} diff --git a/backend/internal/service/openai_responses_input_compat_test.go b/backend/internal/service/openai_responses_input_compat_test.go new file mode 100644 index 0000000000..fc9fa7418a --- /dev/null +++ b/backend/internal/service/openai_responses_input_compat_test.go @@ -0,0 +1,163 @@ +package service + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { + t.Run("preserves matches regardless of item order", func(t *testing.T) { + input := []any{ + map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"}, + map[string]any{"type": "tool_search_call", "id": "search_1", "query": "docs"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "custom_1", "output": "second"}, + map[string]any{"type": "item_reference", "id": "custom_1"}, + } + reqBody := map[string]any{"input": input} + + require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false)) + require.Equal(t, input, reqBody["input"]) + }) + + t.Run("outputs do not legitimize each other", func(t *testing.T) { + input := []any{ + map[string]any{"type": "function_call_output", "call_id": "missing", "output": "one"}, + map[string]any{"type": "tool_search_output", "call_id": "missing", "output": "two"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "missing", "output": "three"}, + map[string]any{"type": "mcp_tool_call_output", "call_id": "missing", "output": "four"}, + } + reqBody := map[string]any{"input": input} + + require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false)) + got, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Empty(t, got) + }) + + t.Run("preserves all output variants with matching calls", func(t *testing.T) { + pairs := []struct { + callType string + outputType string + }{ + {callType: "function_call", outputType: "function_call_output"}, + {callType: "tool_search_call", outputType: "tool_search_output"}, + {callType: "custom_tool_call", outputType: "custom_tool_call_output"}, + {callType: "mcp_tool_call", outputType: "mcp_tool_call_output"}, + } + input := make([]any, 0, len(pairs)*2) + for index, pair := range pairs { + callID := string(rune('a' + index)) + input = append(input, + map[string]any{"type": pair.callType, "call_id": callID}, + map[string]any{"type": pair.outputType, "call_id": callID, "output": "ok"}, + ) + } + reqBody := map[string]any{"input": input} + + require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false)) + }) + + t.Run("previous response may contain the missing call", func(t *testing.T) { + input := []any{map[string]any{"type": "function_call_output", "call_id": "remote", "output": "ok"}} + reqBody := map[string]any{"input": input, "previous_response_id": "resp_1"} + + require.False(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, true)) + require.Equal(t, input, reqBody["input"]) + }) +} + +func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) { + atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars) + oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中" + input := []any{ + map[string]any{"type": "function_call_output", "call_id": "limit", "output": atLimit}, + map[string]any{"type": "function_call_output", "call_id": "a", "output": oversized}, + map[string]any{"type": "tool_search_output", "call_id": "b", "output": oversized}, + map[string]any{"type": "custom_tool_call_output", "call_id": "c", "output": oversized}, + map[string]any{"type": "mcp_tool_call_output", "call_id": "d", "output": oversized}, + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "input_text", "text": "short"}, + map[string]any{"type": "input_text", "text": oversized}, + }, + }, + } + reqBody := map[string]any{"input": input} + + require.False(t, truncateOpenAIResponsesInputText(reqBody)) + first, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, atLimit, first["output"]) + for _, rawItem := range input[1:5] { + item, ok := rawItem.(map[string]any) + require.True(t, ok) + require.Equal(t, oversized, item["output"]) + } + last, ok := input[5].(map[string]any) + require.True(t, ok) + content, ok := last["content"].([]any) + require.True(t, ok) + require.Len(t, content, 2) + shortPart, ok := content[0].(map[string]any) + require.True(t, ok) + oversizedPart, ok := content[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "short", shortPart["text"]) + require.Equal(t, oversized, oversizedPart["text"]) +} + +func TestOpenAIResponsesInputNeverRequestsPreemptiveTruncation(t *testing.T) { + short := []byte(`{"input":[{"type":"function_call_output","output":"ok"}]}`) + largeUnrelated := []byte(`{"input":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}`) + largeOutput := []byte(`{"input":[{"type":"function_call_output","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`) + + require.False(t, openAIResponsesInputMayNeedTruncation(short)) + require.False(t, openAIResponsesInputMayNeedTruncation(largeUnrelated)) + require.False(t, openAIResponsesInputMayNeedTruncation(largeOutput)) +} + +func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"previous_response_id":"resp_missing","input":[{"type":"function_call_output","call_id":"call_missing","output":"keep this result"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + newOpenAIRejectedFieldTestContext(body), + newOpenAIOAuthNamespaceTestAccount(), + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + require.False(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists()) + require.Empty(t, gjson.GetBytes(upstream.bodies[0], "input").Array()) +} + +func TestOpenAIGatewayService_PreservesOversizedToolOutputForUpstream(t *testing.T) { + oversized := strings.Repeat("x", openAIResponsesInputTextMaxChars) + "中" + body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + oversized + `"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + newOpenAIRejectedFieldTestContext(body), + newOpenAIRejectedFieldTestAccount(), + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + require.Equal(t, oversized, gjson.GetBytes(upstream.bodies[0], "input.1.output").String()) +} diff --git a/backend/internal/service/openai_responses_input_tokens_test.go b/backend/internal/service/openai_responses_input_tokens_test.go new file mode 100644 index 0000000000..5258d4bb70 --- /dev/null +++ b/backend/internal/service/openai_responses_input_tokens_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestForwardResponsesInputTokensCustomRelayUsesLocalEstimate(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 159, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "relay-key", + "base_url": "https://relay.example/v1", + }, + } + body := []byte(`{"model":"gpt-5.4","instructions":"Be concise.","input":"hello world","tools":[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":"object"}}]}`) + + err := svc.ForwardResponsesInputTokens(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "response.input_tokens", gjson.Get(recorder.Body.String(), "object").String()) + require.Positive(t, gjson.Get(recorder.Body.String(), "input_tokens").Int()) + require.Nil(t, upstream.lastReq, "custom relay must not receive /v1/responses/input_tokens") +} + +func TestForwardResponsesInputTokensGrokOAuthUsesLocalEstimate(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ID: 160, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"model":"grok-4.1","input":"hello world"}`) + + err := svc.ForwardResponsesInputTokens(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "response.input_tokens", gjson.Get(recorder.Body.String(), "object").String()) + require.Positive(t, gjson.Get(recorder.Body.String(), "input_tokens").Int()) + require.Nil(t, upstream.lastReq) +} + +func TestForwardResponsesInputTokensUpstream404FallsBackLocally(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"Invalid URL (POST /v1/responses/input_tokens)"}}`)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 171, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "official-key", + "base_url": "https://api.openai.com/v1", + }, + } + body := []byte(`{"model":"gpt-5.4","instructions":"Be concise.","input":"hello world"}`) + + err := svc.ForwardResponsesInputTokens(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "response.input_tokens", gjson.Get(recorder.Body.String(), "object").String()) + require.Positive(t, gjson.Get(recorder.Body.String(), "input_tokens").Int()) + require.NotNil(t, upstream.lastReq) +} diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go index 34443944b2..3a10966e89 100644 --- a/backend/internal/service/openai_responses_item_id.go +++ b/backend/internal/service/openai_responses_item_id.go @@ -8,22 +8,58 @@ import ( "github.com/tidwall/sjson" ) +func openAIResponsesInputItemIDPrefix(itemType string) (string, bool) { + switch strings.TrimSpace(itemType) { + case "message": + return "msg", true + case "reasoning": + return "rs", true + case "web_search_call": + return "ws", true + case "custom_tool_call": + return openAIResponsesToolCallIDPrefix(itemType), true + case "tool_search_call": + return openAIResponsesToolCallIDPrefix(itemType), true + case "custom_tool_call_output": + // Although custom calls use ctc IDs, OpenAI validates replayed custom + // call output item IDs against the generic fc namespace. + return "fc", true + default: + if isCodexToolCallInputType(itemType) { + return openAIResponsesToolCallIDPrefix(itemType), true + } + return "", false + } +} + +func openAIResponsesToolCallIDPrefix(itemType string) string { + switch strings.TrimSpace(itemType) { + case "custom_tool_call", "custom_tool_call_output": + return "ctc" + case "tool_search_call", "tool_search_output": + return "tsc" + default: + return "fc" + } +} + // Invalid replayed IDs are removed rather than rewritten because a fabricated -// msg/fc ID may point at a different upstream object. +// ID may point at a different upstream object. func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool { - if id == "" { + prefix, constrained := openAIResponsesInputItemIDPrefix(itemType) + if !constrained { return false } - if itemType == "message" { - return !strings.HasPrefix(id, "msg") + return id == "" || !strings.HasPrefix(id, prefix) +} + +func shouldStripOpenAIResponsesNonPairCallID(itemType string) bool { + switch strings.TrimSpace(itemType) { + case "message", "reasoning", "image_generation_call": + return true + default: + return false } - if itemType == "reasoning" { - return !strings.HasPrefix(id, "rs") - } - if isCodexToolCallInputType(itemType) { - return !strings.HasPrefix(id, "fc") - } - return false } func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) { @@ -32,40 +68,61 @@ func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) { return body, false, nil } - items := make([][]byte, 0) - changed := false - var sanitizeErr error - index := 0 + type inputItem struct { + body []byte + stripID bool + stripCallID bool + } + + items := make([]inputItem, 0) input.ForEach(func(_, item gjson.Result) bool { - currentIndex := index - index++ - itemBody := []byte(item.Raw) + parsed := inputItem{body: []byte(item.Raw)} if item.IsObject() { itemType := item.Get("type") id := item.Get("id") - if itemType.Type == gjson.String && id.Type == gjson.String && - shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) { - itemBody, sanitizeErr = sjson.DeleteBytes(itemBody, "id") - if sanitizeErr != nil { - sanitizeErr = fmt.Errorf("delete input.%d.id: %w", currentIndex, sanitizeErr) - return false - } - changed = true + trimmedItemType := strings.TrimSpace(itemType.String()) + parsed.stripCallID = item.Get("call_id").Exists() && shouldStripOpenAIResponsesNonPairCallID(trimmedItemType) + if id.Type == gjson.String { + parsed.stripID = shouldStripOpenAIResponsesInputItemID(trimmedItemType, id.String()) } } - items = append(items, itemBody) + items = append(items, parsed) return true }) - if sanitizeErr != nil { - return nil, false, sanitizeErr + hasSanitization := false + for _, item := range items { + if item.stripID || item.stripCallID { + hasSanitization = true + break + } } - if !changed { + if !hasSanitization { return body, false, nil } + rebuiltItems := make([][]byte, 0, len(items)) + for index, item := range items { + itemBody := item.body + if item.stripID { + var err error + itemBody, err = sjson.DeleteBytes(itemBody, "id") + if err != nil { + return nil, false, fmt.Errorf("delete input.%d.id: %w", index, err) + } + } + if item.stripCallID { + var err error + itemBody, err = sjson.DeleteBytes(itemBody, "call_id") + if err != nil { + return nil, false, fmt.Errorf("delete input.%d.call_id: %w", index, err) + } + } + rebuiltItems = append(rebuiltItems, itemBody) + } + rebuiltInput := make([]byte, 0, len(input.Raw)) rebuiltInput = append(rebuiltInput, '[') - for i, item := range items { + for i, item := range rebuiltItems { if i > 0 { rebuiltInput = append(rebuiltInput, ',') } diff --git a/backend/internal/service/openai_responses_item_id_test.go b/backend/internal/service/openai_responses_item_id_test.go new file mode 100644 index 0000000000..b300b22a2e --- /dev/null +++ b/backend/internal/service/openai_responses_item_id_test.go @@ -0,0 +1,158 @@ +package service + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIResponsesInputItemIDPrefixUsesObservedOutputContracts(t *testing.T) { + tests := []struct { + itemType string + id string + strip bool + }{ + {itemType: "message", id: "msg_123", strip: false}, + {itemType: "message", id: "item_123", strip: true}, + {itemType: "reasoning", id: "rs_123", strip: false}, + {itemType: "reasoning", id: "item_123", strip: true}, + {itemType: "function_call", id: "fc_123", strip: false}, + {itemType: "function_call", id: "call_123", strip: true}, + {itemType: "tool_call", id: "fc_123", strip: false}, + {itemType: "local_shell_call", id: "fc_123", strip: false}, + {itemType: "mcp_tool_call", id: "fc_123", strip: false}, + {itemType: "custom_tool_call", id: "ctc_123", strip: false}, + {itemType: "custom_tool_call", id: "fc_123", strip: true}, + {itemType: "tool_search_call", id: "tsc_123", strip: false}, + {itemType: "tool_search_call", id: "fc_123", strip: true}, + {itemType: "web_search_call", id: "ws_123", strip: false}, + {itemType: "web_search_call", id: "item_123", strip: true}, + {itemType: "custom_tool_call_output", id: "fc_123", strip: false}, + {itemType: "custom_tool_call_output", id: "ctco_123", strip: true}, + // Do not impose an inferred contract on output types for which there is + // no observed upstream prefix rejection. + {itemType: "function_call_output", id: "fco_123", strip: false}, + {itemType: "tool_search_output", id: "tso_123", strip: false}, + {itemType: "mcp_tool_call_output", id: "mcpo_123", strip: false}, + {itemType: "future_item", id: "item_123", strip: false}, + } + for _, tt := range tests { + t.Run(tt.itemType+"/"+tt.id, func(t *testing.T) { + require.Equal(t, tt.strip, shouldStripOpenAIResponsesInputItemID(tt.itemType, tt.id)) + }) + } +} + +func TestSanitizeOpenAIResponsesInputItemIDsDoesNotCascadeAcrossIDNamespaces(t *testing.T) { + body := []byte(`{"input":[ + {"type":"function_call","id":"item_bad_call","call_id":"call_valid","name":"lookup","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_valid","output":"preserve paired output"}, + {"type":"function_call_output","call_id":"item_bad_call","output":"preserve opaque output"}, + {"type":"item_reference","id":"item_bad_call"}, + {"type":"item_reference","id":"remote_valid"}, + {"type":"custom_tool_call","id":"ctc_valid","call_id":"ctco_bad_output","name":"apply_patch","input":"patch"}, + {"type":"custom_tool_call_output","id":"ctco_bad_output","call_id":"ctco_bad_output","output":"preserve by call_id"} + ]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + + require.NoError(t, err) + require.True(t, changed) + items := gjson.GetBytes(sanitized, "input").Array() + require.Len(t, items, 7) + require.False(t, items[0].Get("id").Exists()) + require.Equal(t, "call_valid", items[0].Get("call_id").String()) + require.Equal(t, "preserve paired output", items[1].Get("output").String()) + require.Equal(t, "preserve opaque output", items[2].Get("output").String()) + require.Equal(t, "item_bad_call", items[3].Get("id").String()) + require.Equal(t, "remote_valid", items[4].Get("id").String()) + require.Equal(t, "ctc_valid", items[5].Get("id").String()) + require.False(t, items[6].Get("id").Exists()) + require.Equal(t, "ctco_bad_output", items[6].Get("call_id").String()) +} + +func TestSanitizeOpenAIResponsesInputItemIDsLeavesUnrelatedReferencesUntouched(t *testing.T) { + body := []byte(`{"previous_response_id":"resp_1","input":[{"type":"item_reference","id":"remote_item"}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, body, sanitized) +} + +func TestSanitizeOpenAIResponsesInputItemIDsPreservesReferenceToDuplicateRetainedID(t *testing.T) { + body := []byte(`{"input":[{"type":"function_call","id":"ctc_shared","call_id":"call_1"},{"type":"custom_tool_call","id":"ctc_shared","call_id":"call_2"},{"type":"item_reference","id":"ctc_shared"}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists()) + require.Equal(t, "ctc_shared", gjson.GetBytes(sanitized, "input.1.id").String()) + require.Equal(t, "ctc_shared", gjson.GetBytes(sanitized, "input.2.id").String()) +} + +func TestSanitizeOpenAIResponsesInputItemIDsPreservesOpaqueOutputsAndReferences(t *testing.T) { + body := []byte(`{"input":[ + {"type":"function_call","id":"item_shared","call_id":"call_real"}, + {"type":"function_call_output","id":"item_shared","call_id":"item_shared","output":"dangling"}, + {"type":"item_reference","id":"item_shared"}, + {"type":"function_call_output","id":"kept_output","call_id":"call_real","output":"kept"}, + {"type":"item_reference","id":"kept_output"} + ]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + + require.NoError(t, err) + require.True(t, changed) + require.Len(t, gjson.GetBytes(sanitized, "input").Array(), 5) + require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists()) + require.Equal(t, "dangling", gjson.GetBytes(sanitized, "input.1.output").String()) + require.Equal(t, "item_shared", gjson.GetBytes(sanitized, "input.2.id").String()) + require.Equal(t, "kept_output", gjson.GetBytes(sanitized, "input.3.id").String()) + require.Equal(t, "kept_output", gjson.GetBytes(sanitized, "input.4.id").String()) + + second, changedAgain, err := sanitizeOpenAIResponsesInputItemIDs(sanitized) + require.NoError(t, err) + require.False(t, changedAgain) + require.Equal(t, sanitized, second) +} + +func TestSanitizeOpenAIResponsesInputItemIDsStripsEmptyKnownIDsOnly(t *testing.T) { + body := []byte(`{"input":[{"type":"message","id":"","content":"hello"},{"type":"future_item","id":""}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists()) + require.True(t, gjson.GetBytes(sanitized, "input.1.id").Exists()) +} + +func TestSanitizeOpenAIResponsesInputItemIDsStripsOnlyNonPairCallIDs(t *testing.T) { + body := []byte(`{"input":[ + {"type":"message","call_id":"remove_message","content":"hi"}, + {"type":"reasoning","call_id":"remove_reasoning","id":"rs_keep","encrypted_content":"cipher","summary":[]}, + {"type":"image_generation_call","call_id":"remove_image","id":"ig_keep","status":"completed"}, + {"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"}, + {"type":"function_call_output","call_id":"keep_function","output":"ok"}, + {"type":"custom_tool_call","call_id":"keep_custom","name":"patch","input":"x"}, + {"type":"custom_tool_call_output","call_id":"keep_custom","output":"ok"}, + {"type":"tool_search_call","call_id":"keep_search","arguments":"{}"}, + {"type":"tool_search_output","call_id":"keep_search","output":"ok"}, + {"type":"local_shell_call","call_id":"keep_shell","name":"shell","arguments":"{}"} + ]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + require.NoError(t, err) + require.True(t, changed) + for i := 0; i < 3; i++ { + require.False(t, gjson.GetBytes(sanitized, "input."+strconv.Itoa(i)+".call_id").Exists()) + } + for i := 3; i < 10; i++ { + require.True(t, gjson.GetBytes(sanitized, "input."+strconv.Itoa(i)+".call_id").Exists()) + } +} diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index c5c16ecaa1..7f6c7f58e5 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -1,12 +1,22 @@ package service import ( - "encoding/json" "fmt" "reflect" "strings" ) +type openAIResponsesLiteValidationError struct { + param string + message string +} + +func (e *openAIResponsesLiteValidationError) Error() string { return e.message } + +func newOpenAIResponsesLiteValidationError(param, format string, args ...any) error { + return &openAIResponsesLiteValidationError{param: param, message: fmt.Sprintf(format, args...)} +} + // normalizeOpenAIResponsesLiteTools applies the Responses Lite request // contract: reasoning must cover all turns, and private namespace declarations // use the input.additional_tools carrier. Other top-level tools must belong to @@ -16,18 +26,27 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { if reqBody == nil { return false, nil } + if parallel, exists := reqBody["parallel_tool_calls"]; exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil { if _, ok := rawReasoning.(map[string]any); !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } } rawTools, exists := reqBody["tools"] if !exists || rawTools == nil { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } tools, ok := rawTools.([]any) if !ok { - return false, fmt.Errorf("responses Lite requires tools to be an array") + return false, newOpenAIResponsesLiteValidationError("tools", "responses Lite requires tools to be an array") } topLevelTools := make([]any, 0, len(tools)) @@ -57,7 +76,11 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } } if len(namespaceTools) == 0 { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools) @@ -73,6 +96,20 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } else { reqBody["tools"] = topLevelTools } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, true) +} + +func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) { + parallel, exists := reqBody["parallel_tool_calls"] + if exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } + if parallel == false { + return changed, nil + } + reqBody["parallel_tool_calls"] = false return true, nil } @@ -84,7 +121,7 @@ func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, er } reasoning, ok := rawReasoning.(map[string]any) if !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } if context, ok := reasoning["context"].(string); ok && context == "all_turns" { return false, nil @@ -201,7 +238,7 @@ func openAIResponsesLiteToolIdentityForError(rawTool any) string { func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) { var requestBody map[string]any - if err := json.Unmarshal(body, &requestBody); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil { return body, false, fmt.Errorf("decode responses Lite request body: %w", err) } changed, err := normalizeOpenAIResponsesLiteTools(requestBody) @@ -214,3 +251,29 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) } return rebuilt, true, nil } + +func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) { + var requestBody map[string]any + if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil { + return body, false, fmt.Errorf("decode responses Lite request body: %w", err) + } + changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false) + if err != nil || !changed { + return body, false, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, false, fmt.Errorf("encode responses Lite request body: %w", err) + } + return rebuilt, true, nil +} + +func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) { + if account == nil || !account.IsOpenAI() { + return body, false, nil + } + if account.IsOpenAIOAuthLike() { + return normalizeOpenAIResponsesLiteToolsPayload(body) + } + return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body) +} diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index b76e97e272..4088882d24 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -58,6 +58,21 @@ func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"]) } +func TestNormalizeOpenAIResponsesLiteTools_PreservesDeferredFlagsWithToolSearch(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + }, + } + + _, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + tools := reqBody["tools"].([]any) + require.Equal(t, "tool_search", tools[0].(map[string]any)["type"]) + require.Equal(t, true, tools[1].(map[string]any)["defer_loading"]) +} + func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) { reqBody := map[string]any{ "tools": []any{map[string]any{ @@ -147,8 +162,111 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin changed, err := normalizeOpenAIResponsesLiteTools(reqBody) require.NoError(t, err) - require.False(t, changed) + require.True(t, changed) require.Len(t, reqBody["tools"], 4) + require.Equal(t, false, reqBody["parallel_tool_calls"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testing.T) { + tests := []struct { + name string + body map[string]any + }{ + { + name: "top-level tools", + body: map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + }, + }, + { + name: "input additional tools", + body: map[string]any{ + "input": []any{map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{"type": "namespace", "name": "collaboration"}}, + }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed, err := normalizeOpenAIResponsesLiteTools(tt.body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, tt.body["parallel_tool_calls"]) + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_PinsParallelToolCallsWithoutTools(t *testing.T) { + tests := []struct { + name string + parallel any + include bool + wantChanged bool + }{ + {name: "字段缺失", wantChanged: true}, + {name: "值为 true", parallel: true, include: true, wantChanged: true}, + {name: "值为 false", parallel: false, include: true, wantChanged: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}} + if tt.include { + reqBody["parallel_tool_calls"] = tt.parallel + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.Equal(t, tt.wantChanged, changed) + require.Contains(t, reqBody, "parallel_tool_calls") + require.Equal(t, false, reqBody["parallel_tool_calls"]) + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { + for _, value := range []any{"false", float64(0), nil, map[string]any{}} { + reqBody := map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": value, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) + require.Equal(t, value, reqBody["parallel_tool_calls"]) + } + + reqBody := map[string]any{"parallel_tool_calls": []any{}} + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) +} + +func TestNormalizeOpenAIResponsesLiteTools_ParallelToolCallsIsIdempotent(t *testing.T) { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) + + changed, err = normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) } func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) { @@ -230,6 +348,36 @@ func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t require.False(t, gjson.GetBytes(updated, "tools").Exists()) require.Equal(t, "collaboration", gjson.GetBytes(updated, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(updated, "tool_choice.type").String()) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) +} + +func TestNormalizeOpenAIResponsesLitePayloads_PreserveLargeSequence(t *testing.T) { + body := []byte(`{ + "type":"response.create", + "sequence":900719925474099312345, + "tools":[{"type":"function","name":"lookup"}], + "parallel_tool_calls":true + }`) + tests := []struct { + name string + normalize func([]byte) ([]byte, bool, error) + }{ + {name: "OAuth-like tools normalization", normalize: normalizeOpenAIResponsesLiteToolsPayload}, + {name: "API key parallel normalization", normalize: normalizeOpenAIResponsesLiteParallelToolCallsPayload}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, changed, err := tt.normalize(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "900719925474099312345", gjson.GetBytes(updated, "sequence").Raw) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) + }) + } } func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) { @@ -282,6 +430,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes body := []byte(`{ "model":"gpt-5.6-terra","stream":true,"instructions":"test", "reasoning":{"effort":"high","context":"current_turn"}, + "parallel_tool_calls":true, "tools":[ {"type":"function","name":"shell","parameters":{"type":"object"}}, {"type":"custom","name":"exec"}, @@ -306,6 +455,160 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String()) require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + + badRec := httptest.NewRecorder() + badCtx, _ := gin.CreateTestContext(badRec) + badCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + badCtx.Request.Header.Set(responsesLiteHeader, "true") + badUpstream := &httpUpstreamRecorder{} + svc.httpUpstream = badUpstream + + result, err = svc.Forward(context.Background(), badCtx, account, []byte(`{"model":"gpt-5.6-terra","tools":[{"type":"function","name":"shell"}],"parallel_tool_calls":"false"}`)) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, badRec.Code) + require.Equal(t, "invalid_request_error", gjson.Get(badRec.Body.String(), "error.type").String()) + require.Equal(t, "parallel_tool_calls", gjson.Get(badRec.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(badRec.Body.String(), "error.message").String(), "parallel_tool_calls to be a boolean") + require.Nil(t, badUpstream.lastReq) + + for _, malformed := range []struct { + body string + wantParam string + }{ + {body: `{"model":"gpt-5.6-terra","tools":{}}`, wantParam: "tools"}, + {body: `{"model":"gpt-5.6-terra","reasoning":[]}`, wantParam: "reasoning"}, + } { + rec := httptest.NewRecorder() + requestCtx, _ := gin.CreateTestContext(rec) + requestCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + requestCtx.Request.Header.Set(responsesLiteHeader, "true") + + result, err = svc.Forward(context.Background(), requestCtx, account, []byte(malformed.body)) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, malformed.wantParam, gjson.Get(rec.Body.String(), "error.param").String()) + } + }) + } +} + +func TestOpenAIGatewayServiceForward_PinsParallelToolCallsForToollessResponsesLite(t *testing.T) { + gin.SetMode(gin.TestMode) + + accountCases := []struct { + name string + accountType string + credentials map[string]any + }{ + {name: "oauth", accountType: AccountTypeOAuth, credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}}, + {name: "apikey", accountType: AccountTypeAPIKey, credentials: map[string]any{"api_key": "sk-test"}}, + } + parallelCases := []struct { + name string + field string + }{ + {name: "字段缺失"}, + {name: "值为 true", field: `,"parallel_tool_calls":true`}, + {name: "值为 false", field: `,"parallel_tool_calls":false`}, + } + + for _, accountCase := range accountCases { + for _, passthrough := range []bool{false, true} { + mode := "managed" + if passthrough { + mode = "passthrough" + } + for _, parallelCase := range parallelCases { + name := accountCase.name + "/" + mode + "/" + parallelCase.name + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: accountCase.accountType, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: accountCase.credentials, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "reasoning":{"effort":"high","context":"current_turn"}, + "input":[{"type":"message","role":"user","content":"hello"}]` + parallelCase.field + ` + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.Equal(t, gjson.False, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Type, string(upstream.lastBody)) + }) + } + } + } +} + +func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "parallel_tool_calls":true, + "input":[{"type":"message","role":"user","content":"hello"}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) }) } } diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index 73760f567b..f4fd47b13b 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -42,7 +42,7 @@ func shouldFlattenOpenAIResponsesNamespaces( passthroughEnabled bool, compactPath bool, ) bool { - if account == nil || !account.IsOpenAIOAuth() { + if account == nil || !account.IsOpenAIOAuthLike() { return false } if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() { @@ -58,7 +58,7 @@ func shouldFlattenOpenAIResponsesNamespaces( // namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps // namespaces because that protocol supports them and does not restore payloads. func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool { - if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) { + if account == nil || (!account.IsOpenAIOAuthLike() && !account.IsOpenAIApiKey()) { return false } if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled { @@ -87,7 +87,7 @@ func shouldKeepOpenAIResponsesToolCallNamespaces( passthroughEnabled bool, compactPath bool, ) bool { - if account == nil || !account.IsOpenAIOAuth() { + if account == nil || !account.IsOpenAIOAuthLike() { return false } if compactPath { diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go index 80095f3293..7a9b47b7f5 100644 --- a/backend/internal/service/openai_responses_namespace_test.go +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -94,7 +94,7 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) { {name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false}, // API Key 出口是标准 Responses API,不认识该字段。 {name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, - {name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "setup_token_keeps", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, } for _, tt := range tests { @@ -125,7 +125,7 @@ func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) { {name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, {name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, {name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, - {name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, {name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, } diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go index d8d2bb02bf..f70bdd89ac 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry.go +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -7,7 +7,9 @@ import ( "regexp" "strconv" "strings" + "sync" + "github.com/gin-gonic/gin" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -15,17 +17,58 @@ import ( const maxOpenAIResponsesRejectedFieldRetries = 6 var ( - openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`) - openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.namespace)(?:["']|\b)`) + openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`) + openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`) + openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`) + openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`) + openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`) + openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`) + openAIResponsesMaxZeroContentMessagePattern = regexp.MustCompile(`(?i)invalid\s+["']?(input\[\d+\]\.content)["']?\s*:\s*array too long\.[^\n]*maximum length 0\b`) + openAIResponsesCacheModelRejectionPattern = regexp.MustCompile(`(?i)["']?(prompt_cache_breakpoint|input\[\d+\]\.prompt_cache_breakpoint)["']?\s+is\s+not\s+supported\s+on\s+this\s+model\b`) + openAIResponsesToolParametersParamPattern = regexp.MustCompile(`(?i)^(?:tools|input)\[\d+\](?:\.tools\[\d+\])*(?:\.function)?\.parameters$`) + openAIResponsesMissingSchemaTypePattern = regexp.MustCompile(`(?i)\bgot\s+["']?type\s*:\s*["']?none["']?`) ) type openAIResponsesRejectedFieldRetryState struct { - attempts int + mu sync.Mutex + budget *openAIResponsesRejectedFieldRetryBudget seenBodyHashes map[[sha256.Size]byte]struct{} } +type openAIResponsesRejectedFieldRetryBudget struct { + mu sync.Mutex + attempts int +} + +const openAIResponsesRejectedFieldRetryBudgetContextKey = "openai_responses_rejected_field_retry_budget" + +// openAIResponsesRejectedFieldRetryStateForRequest returns a fresh loop guard +// for one account attempt backed by the inbound request's shared retry budget. +// A later account may apply the same compatibility transform, while all account +// attempts together remain bounded. +func openAIResponsesRejectedFieldRetryStateForRequest(c *gin.Context, initialBody []byte) *openAIResponsesRejectedFieldRetryState { + var budget *openAIResponsesRejectedFieldRetryBudget + if c != nil { + if existing, ok := c.Get(openAIResponsesRejectedFieldRetryBudgetContextKey); ok { + budget, _ = existing.(*openAIResponsesRejectedFieldRetryBudget) + } + } + if budget == nil { + budget = &openAIResponsesRejectedFieldRetryBudget{} + if c != nil { + c.Set(openAIResponsesRejectedFieldRetryBudgetContextKey, budget) + } + } + return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, budget) +} + func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState { + return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, &openAIResponsesRejectedFieldRetryBudget{}) +} + +func newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody []byte, budget *openAIResponsesRejectedFieldRetryBudget) *openAIResponsesRejectedFieldRetryState { state := &openAIResponsesRejectedFieldRetryState{ + budget: budget, seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1), } state.remember(initialBody) @@ -33,15 +76,22 @@ func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIRespon } func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool { - if s == nil || len(nextBody) == 0 || s.attempts >= maxOpenAIResponsesRejectedFieldRetries { + if s == nil || s.budget == nil || len(nextBody) == 0 { return false } + s.mu.Lock() + defer s.mu.Unlock() bodyHash := sha256.Sum256(nextBody) if _, seen := s.seenBodyHashes[bodyHash]; seen { return false } + s.budget.mu.Lock() + defer s.budget.mu.Unlock() + if s.budget.attempts >= maxOpenAIResponsesRejectedFieldRetries { + return false + } s.seenBodyHashes[bodyHash] = struct{}{} - s.attempts++ + s.budget.attempts++ return true } @@ -49,6 +99,12 @@ func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) { if s == nil || len(body) == 0 { return } + s.mu.Lock() + defer s.mu.Unlock() + s.rememberLocked(body) +} + +func (s *openAIResponsesRejectedFieldRetryState) rememberLocked(body []byte) { if s.seenBodyHashes == nil { s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1) } @@ -62,23 +118,80 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody))) message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody))) - if !isExplicitOpenAIResponsesFieldRejection(code, message) { - return nil, "", false, nil + param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String())) + if code == "invalid_function_parameters" && + openAIResponsesToolParametersParamPattern.MatchString(param) && + openAIResponsesMissingSchemaTypePattern.MatchString(message) { + retryBody, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + if err != nil { + return nil, "", false, fmt.Errorf("repair rejected tool parameter root type: %w", err) + } + if changed { + return retryBody, "tool parameter root type rejection", true, nil + } + } + cacheMessageParam := openAIResponsesCacheModelRejectionParamFromMessage(message) + cacheParam := param + if cacheParam == "" { + cacheParam = cacheMessageParam + } + cacheParamMatchesMessage := cacheMessageParam == "" || cacheParam == cacheMessageParam + cacheModelRejection := code == "invalid_parameter" || cacheMessageParam != "" + if cacheParam != "" && cacheParamMatchesMessage && cacheModelRejection { + if cacheParam == "prompt_cache_breakpoint" && gjson.GetBytes(body, cacheParam).Exists() { + retryBody, err := sjson.DeleteBytes(body, cacheParam) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint: %w", err) + } + return retryBody, "prompt_cache_breakpoint parameter rejection", true, nil + } + if index, ok := openAIResponsesRejectedCacheIndex(cacheParam); ok { + return removeOpenAIResponsesRejectedCacheAtIndex(body, index) + } + } + if isExplicitOpenAIResponsesFieldRejection(code, message) { + messageParam := openAIResponsesRejectedParamFromMessage(message) + if param != "" && messageParam != "" && param != messageParam { + return nil, "", false, nil + } + if param == "" { + param = messageParam + } + if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok { + return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index) + } + if index, ok := openAIResponsesRejectedStatusIndex(param); ok { + return removeOpenAIResponsesRejectedStatusAtIndex(body, index) + } + if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() { + retryBody, err := sjson.DeleteBytes(body, "max_output_tokens") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err) + } + return retryBody, "max_output_tokens parameter rejection", true, nil + } + if param == "truncation" && gjson.GetBytes(body, "truncation").Exists() { + retryBody, err := sjson.DeleteBytes(body, "truncation") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected truncation: %w", err) + } + return retryBody, "truncation parameter rejection", true, nil + } } - param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String())) - if param == "" { - param = openAIResponsesRejectedParamFromMessage(message) + messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message) + contentParam := param + if contentParam == "" { + contentParam = messageContentParam } - if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok { - return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index) + if index, ok := openAIResponsesRejectedContentIndex(contentParam); ok && + contentParam == messageContentParam && isExplicitOpenAIResponsesNullContentRejection(code, message) { + return normalizeOpenAIResponsesRejectedNullContentAtIndex(body, index) } - if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() { - retryBody, err := sjson.DeleteBytes(body, "max_output_tokens") - if err != nil { - return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err) - } - return retryBody, "max_output_tokens parameter rejection", true, nil + maxZeroContentParam := openAIResponsesMaxZeroContentParamFromMessage(message) + if index, ok := openAIResponsesRejectedContentIndex(param); ok && + param == maxZeroContentParam && code == "array_above_max_length" { + return removeOpenAIResponsesRejectedReasoningContentAtIndex(body, index) } return nil, "", false, nil } @@ -100,8 +213,54 @@ func openAIResponsesRejectedParamFromMessage(message string) string { return strings.ToLower(strings.TrimSpace(match[1])) } +func openAIResponsesMaxZeroContentParamFromMessage(message string) string { + match := openAIResponsesMaxZeroContentMessagePattern.FindStringSubmatch(strings.TrimSpace(message)) + if len(match) != 2 { + return "" + } + return strings.ToLower(strings.TrimSpace(match[1])) +} + +func openAIResponsesInvalidTypeParamFromMessage(message string) string { + match := openAIResponsesInvalidTypeMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message)) + if len(match) != 2 { + return "" + } + return strings.ToLower(strings.TrimSpace(match[1])) +} + +func openAIResponsesCacheModelRejectionParamFromMessage(message string) string { + match := openAIResponsesCacheModelRejectionPattern.FindStringSubmatch(strings.TrimSpace(message)) + if len(match) != 2 { + return "" + } + return strings.ToLower(strings.TrimSpace(match[1])) +} + +func isExplicitOpenAIResponsesNullContentRejection(code, message string) bool { + code = strings.TrimSpace(code) + return (code == "invalid_type" || code == "invalid_request_error" || code == "") && + openAIResponsesInvalidTypeMessageParamPattern.MatchString(strings.TrimSpace(message)) +} + func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) { - match := openAIResponsesRejectedNamespaceParamPattern.FindStringSubmatch(strings.TrimSpace(param)) + return openAIResponsesRejectedInputIndex(openAIResponsesRejectedNamespaceParamPattern, param) +} + +func openAIResponsesRejectedStatusIndex(param string) (int, bool) { + return openAIResponsesRejectedInputIndex(openAIResponsesRejectedStatusParamPattern, param) +} + +func openAIResponsesRejectedContentIndex(param string) (int, bool) { + return openAIResponsesRejectedInputIndex(openAIResponsesRejectedContentParamPattern, param) +} + +func openAIResponsesRejectedCacheIndex(param string) (int, bool) { + return openAIResponsesRejectedInputIndex(openAIResponsesRejectedCacheParamPattern, param) +} + +func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (int, bool) { + match := pattern.FindStringSubmatch(strings.TrimSpace(param)) if len(match) != 2 { return 0, false } @@ -112,6 +271,119 @@ func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) { return 0, false } +// removeOpenAIResponsesRejectedStatusAtIndex drops the status field the +// upstream rejected, and the status of every other input item sharing the +// rejected item's type. +// +// The upstream names one offending index per response, but a replayed +// conversation routinely carries dozens of items of the same type, each with a +// status its schema does not accept. Clearing one index per round trip would +// need one retry per item and exhaust the bounded retry budget long before the +// request could succeed. Items of other types keep their status: the rejection +// only proves that this type has no status field. +func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) { + itemPath := fmt.Sprintf("input.%d", index) + rejected := gjson.GetBytes(body, itemPath) + if !rejected.IsObject() { + return nil, "", false, nil + } + if !gjson.GetBytes(body, itemPath+".status").Exists() { + return nil, "", false, nil + } + + retryBody := body + cleared := 0 + rejectedType := strings.TrimSpace(rejected.Get("type").String()) + if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() { + // Deleting a field never shifts array indexes, so positions read from + // the original body stay valid against the rewritten one. + for itemIndex, item := range input.Array() { + if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType { + continue + } + statusPath := fmt.Sprintf("input.%d.status", itemIndex) + if !gjson.GetBytes(retryBody, statusPath).Exists() { + continue + } + next, err := sjson.DeleteBytes(retryBody, statusPath) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err) + } + retryBody = next + cleared++ + } + } + if cleared == 0 { + // The rejected item carries no type to match on; fall back to clearing + // just the index the upstream named. + next, err := sjson.DeleteBytes(retryBody, itemPath+".status") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err) + } + retryBody = next + } + return retryBody, "indexed status parameter rejection", true, nil +} + +func removeOpenAIResponsesRejectedCacheAtIndex(body []byte, index int) ([]byte, string, bool, error) { + itemPath := fmt.Sprintf("input.%d", index) + if !gjson.GetBytes(body, itemPath).IsObject() { + return nil, "", false, nil + } + cachePath := itemPath + ".prompt_cache_breakpoint" + if !gjson.GetBytes(body, cachePath).Exists() { + return nil, "", false, nil + } + retryBody, err := sjson.DeleteBytes(body, cachePath) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint at input[%d]: %w", index, err) + } + return retryBody, "indexed prompt_cache_breakpoint parameter rejection", true, nil +} + +func normalizeOpenAIResponsesRejectedNullContentAtIndex(body []byte, index int) ([]byte, string, bool, error) { + itemPath := fmt.Sprintf("input.%d", index) + item := gjson.GetBytes(body, itemPath) + content := gjson.GetBytes(body, itemPath+".content") + if !item.IsObject() || !content.Exists() || content.Type != gjson.Null { + return nil, "", false, nil + } + + itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String())) + role := strings.TrimSpace(item.Get("role").String()) + contentPath := itemPath + ".content" + switch { + case itemType == "reasoning": + retryBody, err := sjson.DeleteBytes(body, contentPath) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected null content at input[%d]: %w", index, err) + } + return retryBody, "indexed reasoning null content rejection", true, nil + case itemType == "message" || role != "": + retryBody, err := sjson.SetBytes(body, contentPath, "") + if err != nil { + return nil, "", false, fmt.Errorf("normalize rejected null content at input[%d]: %w", index, err) + } + return retryBody, "indexed message null content rejection", true, nil + default: + return nil, "", false, nil + } +} + +func removeOpenAIResponsesRejectedReasoningContentAtIndex(body []byte, index int) ([]byte, string, bool, error) { + itemPath := fmt.Sprintf("input.%d", index) + item := gjson.GetBytes(body, itemPath) + content := item.Get("content") + if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != "reasoning" || !content.IsArray() || len(content.Array()) == 0 { + return nil, "", false, nil + } + retryBody, err := sjson.DeleteBytes(body, itemPath+".content") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected reasoning content at input[%d]: %w", index, err) + } + return retryBody, "indexed reasoning content maximum-length rejection", true, nil +} + func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) { itemPath := fmt.Sprintf("input.%d", index) itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String())) diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 724fd90c37..9f794b1e57 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -30,6 +31,33 @@ func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *tes require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`))) } +func TestOpenAIResponsesRejectedFieldRetryStateForRequestAllowsSameTransformAcrossAccounts(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + initialBody := []byte(`{"model":"gpt-5.5","truncation":"auto"}`) + retryBody := []byte(`{"model":"gpt-5.5"}`) + + accountA := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody) + require.True(t, accountA.Allow(retryBody)) + require.False(t, accountA.Allow(retryBody), "one account must not repeat the same transform") + + accountB := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody) + require.NotSame(t, accountA, accountB) + require.Same(t, accountA.budget, accountB.budget) + require.True(t, accountB.Allow(retryBody), "a failover account must be allowed to apply the same transform") +} + +func TestOpenAIResponsesRejectedFieldRetryStateForRequestSharesBoundedBudgetAcrossAccounts(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ { + state := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(fmt.Sprintf(`{"account":%d}`, attempt))) + require.True(t, state.Allow([]byte(`{"same":"retry"}`))) + } + overflow := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(`{"account":"overflow"}`)) + require.False(t, overflow.Allow([]byte(`{"new":"retry"}`))) +} + func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) { tests := []struct { name string @@ -56,6 +84,11 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`), responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`), }, + { + name: "structured target conflicts with message target", + body: []byte(`{"max_output_tokens":4096,"truncation":"auto"}`), + responseBody: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: truncation.","param":"max_output_tokens"}}`), + }, } for _, tt := range tests { @@ -68,6 +101,33 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t } } +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRepairsAutomationMissingRootType(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"object","properties":{}}]}}]}`) + responseBody := []byte(`{"error":{"code":"invalid_function_parameters","message":"Invalid schema for function 'automation_update': got 'type: \"None\"'.","param":"tools[0].parameters"}}`) + + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "tool parameter root type rejection", reason) + require.Equal(t, "object", gjson.GetBytes(retryBody, "tools.0.parameters.type").String()) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotGuessAutomationRootType(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"}]}}]}`) + tests := []string{ + `{"error":{"code":"invalid_function_parameters","message":"got type: \"None\"","param":"metadata.parameters"}}`, + `{"error":{"code":"invalid_request_error","message":"got type: \"None\"","param":"tools[0].parameters"}}`, + `{"error":{"code":"invalid_function_parameters","message":"expected an object","param":"tools[0].parameters"}}`, + } + for _, response := range tests { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, []byte(response)) + require.NoError(t, err) + require.False(t, changed) + require.Nil(t, retryBody) + } +} + func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) { body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`) responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`) @@ -114,6 +174,350 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRej require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists()) } +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactIndexedStatus(t *testing.T) { + body := []byte(`{"input":[{"type":"message","status":"keep","content":"one"},{"type":"reasoning","status":"remove","summary":[]}]}`) + responses := []struct { + name string + body []byte + }{ + { + name: "structured param", + body: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`), + }, + { + name: "message param", + body: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: input[1].status."}}`), + }, + } + for _, tt := range responses { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.status").String()) + require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists()) + }) + } +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyNormalizesExactNullContent(t *testing.T) { + tests := []struct { + name string + body []byte + wantChange bool + wantValue string + wantExists bool + }{ + { + name: "message becomes empty string", + body: []byte(`{"input":[{"type":"message","role":"assistant","content":null}]}`), + wantChange: true, + wantValue: "", + wantExists: true, + }, + { + name: "reasoning content is removed", + body: []byte(`{"input":[{"type":"reasoning","content":null,"summary":[]}]}`), + wantChange: true, + wantExists: false, + }, + { + name: "unknown item is unchanged", + body: []byte(`{"input":[{"type":"future_item","content":null}]}`), + wantChange: false, + }, + { + name: "non null content is unchanged", + body: []byte(`{"input":[{"type":"message","content":"keep"}]}`), + wantChange: false, + }, + } + responseBody := []byte(`{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, responseBody) + require.NoError(t, err) + require.Equal(t, tt.wantChange, changed) + if !tt.wantChange { + require.Nil(t, retryBody) + return + } + content := gjson.GetBytes(retryBody, "input.0.content") + require.Equal(t, tt.wantExists, content.Exists()) + if tt.wantExists { + require.Equal(t, tt.wantValue, content.String()) + require.Equal(t, gjson.String, content.Type) + } + }) + } +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactReasoningContentAboveMaximumZero(t *testing.T) { + body := []byte(`{"input":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"remove"}],"summary":[]},{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`) + responseBody := []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content","type":"invalid_request_error"}}`) + + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "indexed reasoning content maximum-length rejection", reason) + require.False(t, gjson.GetBytes(retryBody, "input.0.content").Exists()) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.1.content.0.text").String()) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeReasoningMaximumZeroMutations(t *testing.T) { + tests := []struct { + name string + body []byte + responseBody []byte + }{ + { + name: "message content is not reasoning", + body: []byte(`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`), + responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`), + }, + { + name: "structured param and message disagree", + body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]},{"type":"reasoning","content":[{"text":"keep too"}]}]}`), + responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[1].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`), + }, + { + name: "different error code", + body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]}]}`), + responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody) + require.NoError(t, err) + require.False(t, changed) + require.Nil(t, retryBody) + }) + } +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExplicitlyRejectedTopLevelTruncation(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","truncation":"auto","input":"keep"}`) + responses := [][]byte{ + []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`), + []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: truncation."}}`), + } + for _, responseBody := range responses { + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "truncation parameter rejection", reason) + require.False(t, gjson.GetBytes(retryBody, "truncation").Exists()) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String()) + } +} + +func TestOpenAIGatewayService_APIKeyRetriesExplicitlyRejectedTopLevelTruncation(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"truncation":"auto","input":"keep"}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "auto", gjson.GetBytes(upstream.bodies[0], "truncation").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists()) + require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input").String()) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeIndexedMutations(t *testing.T) { + tests := []struct { + name string + body []byte + responseBody []byte + }{ + { + name: "nested status path", + body: []byte(`{"input":[{"type":"message","content":{"status":"keep"}}]}`), + responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.status.","param":"input[0].content.status"}}`), + }, + { + name: "status index out of bounds", + body: []byte(`{"input":[{"type":"message","status":"keep"}]}`), + responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[4].status.","param":"input[4].status"}}`), + }, + { + name: "status path only mentioned", + body: []byte(`{"input":[{"type":"message","status":"keep"}]}`), + responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"input[0].status must be completed","param":"input[0].status"}}`), + }, + { + name: "content param and message disagree", + body: []byte(`{"input":[{"type":"message","content":null},{"type":"message","content":null}]}`), + responseBody: []byte(`{"error":{"code":"invalid_type","message":"Invalid type for input[1].content: got null instead.","param":"input[0].content"}}`), + }, + { + name: "content error only mentions null", + body: []byte(`{"input":[{"type":"message","content":null}]}`), + responseBody: []byte(`{"error":{"code":"invalid_type","message":"content cannot be null","param":"input[0].content"}}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody) + require.NoError(t, err) + require.False(t, changed) + require.Nil(t, retryBody) + }) + } +} + +func TestOpenAIGatewayService_OAuthRetriesExactRejectedStatus(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"test","input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"), + }} + upstream.responses[1].Header.Set("Content-Type", "text/event-stream") + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists()) +} + +func TestOpenAIGatewayService_APIKeyRetriesExactRejectedNullMessageContent(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"message","role":"assistant","content":null},{"type":"message","role":"user","content":"continue"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, gjson.Null, gjson.GetBytes(upstream.bodies[0], "input.0.content").Type) + require.Equal(t, gjson.String, gjson.GetBytes(upstream.bodies[1], "input.0.content").Type) + require.Equal(t, "continue", gjson.GetBytes(upstream.bodies[1], "input.1.content").String()) +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesModelRejectedPromptCacheBreakpoint(t *testing.T) { + tests := []struct { + name string + body []byte + responseBody []byte + removedPath string + preserved string + reason string + }{ + { + name: "top level", + body: []byte(`{"model":"gpt-5.6-sol","prompt_cache_breakpoint":{"type":"message_start"},"input":"hello"}`), + responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`), + removedPath: "prompt_cache_breakpoint", + preserved: "input", + reason: "prompt_cache_breakpoint parameter rejection", + }, + { + name: "indexed path from message", + body: []byte(`{"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_start"}},{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`), + responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[1].prompt_cache_breakpoint is not supported on this model"}}`), + removedPath: "input.1.prompt_cache_breakpoint", + preserved: "input.0.prompt_cache_breakpoint", + reason: "indexed prompt_cache_breakpoint parameter rejection", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, tt.reason, reason) + require.False(t, gjson.GetBytes(retryBody, tt.removedPath).Exists()) + require.True(t, gjson.GetBytes(retryBody, tt.preserved).Exists()) + }) + } +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousPromptCacheBreakpointErrors(t *testing.T) { + body := []byte(`{"prompt_cache_breakpoint":{"type":"message_start"},"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`) + tests := []struct { + name string + responseBody []byte + }{ + { + name: "structured param disagrees", + responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`), + }, + { + name: "index out of bounds", + responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[4].prompt_cache_breakpoint is not supported on this model","param":"input[4].prompt_cache_breakpoint"}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.responseBody) + + require.NoError(t, err) + require.False(t, changed) + require.Nil(t, retryBody) + }) + } +} + +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyAcceptsEitherCacheModelRejectionSignal(t *testing.T) { + tests := []struct { + name string + responseBody []byte + }{ + { + name: "invalid parameter code", + responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"This optional cache hint cannot be used here","param":"prompt_cache_breakpoint"}}`), + }, + { + name: "model rejection message", + responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, []byte(`{"prompt_cache_breakpoint":true,"input":"keep"}`), tt.responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(retryBody, "prompt_cache_breakpoint").Exists()) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String()) + }) + } +} + +func TestOpenAIResponsesRejectedFieldRetryStateAllowsPromptCacheBreakpointVariantOnce(t *testing.T) { + body := []byte(`{"input":[{"prompt_cache_breakpoint":{"type":"message_start"}}]}`) + responseBody := []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"input[0].prompt_cache_breakpoint"}}`) + state := newOpenAIResponsesRejectedFieldRetryState(body) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.True(t, state.Allow(retryBody)) + require.False(t, state.Allow(retryBody)) +} + func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward(t *testing.T) { body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`) upstream := &httpUpstreamRecorder{responses: []*http.Response{ @@ -278,3 +682,42 @@ func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response Body: io.NopCloser(strings.NewReader(body)), } } + +// A replayed conversation carries many items of the same type, each with a +// status the upstream schema rejects. One rejection must clear all of them: +// clearing one index per round trip exhausts the bounded retry budget. +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) { + input := make([]string, 0, 12) + for i := 0; i < 10; i++ { + input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`) + } + input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`) + body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`) + + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`) + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.NotEmpty(t, reason) + + for i := 0; i < 10; i++ { + require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(), + "every tool_search_output must lose its status in a single retry, index %d did not", i) + require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(), + "unrelated fields must survive") + } + require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(), + "a different item type keeps its status: the rejection only proves this type has none") +} + +// The rejected item may carry no type to match on. +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) { + body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`) + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String()) + require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists()) +} diff --git a/backend/internal/service/openai_responses_stream_output_items_test.go b/backend/internal/service/openai_responses_stream_output_items_test.go new file mode 100644 index 0000000000..9cfe3f3d78 --- /dev/null +++ b/backend/internal/service/openai_responses_stream_output_items_test.go @@ -0,0 +1,117 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// A terminal event that arrives with an empty output must be rebuilt from the +// items the stream reported, not from delta accumulation. The accumulator +// models only one reasoning and one message, so rebuilding through it collapses +// a multi-item turn into a single fabricated message. +func TestNormalizeResponsesStreamingTerminalOutputPreservesReportedItems(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + + doneItems.Observe([]byte(`{ + "type":"response.output_item.done", + "output_index":0, + "item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}],"encrypted_content":"opaque"} + }`)) + doneItems.Observe([]byte(`{ + "type":"response.output_item.done", + "output_index":1, + "item":{"id":"msg_1","type":"message","status":"completed","phase":"final_answer","role":"assistant","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]} + }`)) + + normalized, changed := normalizeResponsesStreamingTerminalOutput( + []byte(`{"type":"response.completed","response":{"status":"completed","output":[]}}`), + nil, + doneItems, + nil, + ) + require.True(t, changed) + + output := gjson.GetBytes(normalized, "response.output") + require.True(t, output.IsArray()) + require.Len(t, output.Array(), 2, "both reported items must survive") + + require.Equal(t, "reasoning", gjson.GetBytes(normalized, "response.output.0.type").String()) + require.Equal(t, "rs_1", gjson.GetBytes(normalized, "response.output.0.id").String()) + require.Equal(t, "opaque", gjson.GetBytes(normalized, "response.output.0.encrypted_content").String(), + "fields the gateway does not model must survive verbatim") + + require.Equal(t, "message", gjson.GetBytes(normalized, "response.output.1.type").String()) + require.Equal(t, "msg_1", gjson.GetBytes(normalized, "response.output.1.id").String(), + "the reported id must be reused, not regenerated") + require.Equal(t, "completed", gjson.GetBytes(normalized, "response.output.1.status").String()) + require.Equal(t, "final_answer", gjson.GetBytes(normalized, "response.output.1.phase").String()) + require.Equal(t, "shipped", gjson.GetBytes(normalized, "response.output.1.content.0.text").String()) +} + +// Items are ordered by output_index, not by arrival order. +func TestResponsesStreamOutputItemsOrderByOutputIndex(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{"type":"response.output_item.done","output_index":2,"item":{"id":"c","type":"message"}}`)) + doneItems.Observe([]byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"a","type":"reasoning"}}`)) + + built, ok := doneItems.BuildOutput() + require.True(t, ok) + require.Equal(t, "a", gjson.GetBytes(built, "0.id").String()) + require.Equal(t, "c", gjson.GetBytes(built, "1.id").String()) +} + +// A stream that never reports a done item keeps the previous rebuild path. +func TestNormalizeResponsesStreamingTerminalOutputIgnoresNonDoneEvents(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message"}}`)) + doneItems.Observe([]byte(`{"type":"response.output_text.delta","output_index":0,"delta":"hi"}`)) + require.False(t, doneItems.HasItems()) + + raw := []byte(`{"type":"response.completed","response":{"status":"completed","output":[]}}`) + normalized, changed := normalizeResponsesStreamingTerminalOutput(raw, nil, doneItems, nil) + require.False(t, changed) + require.Equal(t, string(raw), string(normalized)) +} + +// The terminal event can arrive with a non-empty but truncated output: the +// stream reported two items, the terminal carries one, and its id was not the +// one the stream reported. The reported items win. +func TestNormalizeResponsesStreamingTerminalOutputRepairsTruncatedOutput(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{ + "type":"response.output_item.done","output_index":0, + "item":{"id":"rs_real","type":"reasoning","status":"in_progress","summary":[]} + }`)) + doneItems.Observe([]byte(`{ + "type":"response.output_item.done","output_index":1, + "item":{"id":"msg_real","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]} + }`)) + + normalized, changed := normalizeResponsesStreamingTerminalOutput([]byte(`{ + "type":"response.completed", + "response":{"status":"completed","output":[{"type":"message","role":"assistant","id":"msg_fabricated","status":"completed","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]}]} + }`), nil, doneItems, nil) + require.True(t, changed) + + require.Len(t, gjson.GetBytes(normalized, "response.output").Array(), 2) + require.Equal(t, "reasoning", gjson.GetBytes(normalized, "response.output.0.type").String()) + require.Equal(t, "rs_real", gjson.GetBytes(normalized, "response.output.0.id").String()) + require.Equal(t, "msg_real", gjson.GetBytes(normalized, "response.output.1.id").String(), + "the id the stream reported must replace the fabricated one") +} + +// A terminal output that is already complete is never rewritten. +func TestNormalizeResponsesStreamingTerminalOutputLeavesCompleteOutputAlone(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{ + "type":"response.output_item.done","output_index":0, + "item":{"id":"msg_real","type":"message","status":"completed"} + }`)) + + raw := []byte(`{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","id":"msg_upstream","status":"completed","vendor":"keep"}]}}`) + normalized, changed := normalizeResponsesStreamingTerminalOutput(raw, nil, doneItems, nil) + require.False(t, changed) + require.Equal(t, string(raw), string(normalized)) +} diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go index 8c7e7b8d18..c77640d0e3 100644 --- a/backend/internal/service/openai_responses_tool_schema.go +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -2,126 +2,706 @@ package service import ( "bytes" + "encoding/json" + "errors" + "fmt" "sort" - - "github.com/tidwall/gjson" + "strconv" + "strings" + "unicode/utf8" ) const ( - // 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体 - // 造成无界递归。 - openAIResponsesToolSchemaMaxDepth = 4 - // JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法, - // 补成 object 与 upstream 对该工具的实际期望一致。 + // Keep this aligned with the gateway's default maximum request-body size. A + // caller with a larger custom limit must not make this compatibility pass an + // unbounded parser or patch accumulator. + openAIResponsesToolSchemaMaxBodySize = 256 << 20 + openAIResponsesToolSchemaMaxDepth = 128 + openAIResponsesToolSchemaMaxEdits = 1 << 20 + openAIResponsesObjectUnionMaxSize = 1 << 20 + openAIResponsesObjectUnionMaxDepth = 32 + openAIResponsesToolSchemaFallbackType = `"object"` - // 显式 null 在 JSON 里只有这一种字面量形态。 - openAIResponsesToolSchemaNullLiteral = "null" ) -// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的 -// 绝对字节偏移表示,便于最后一次性拼接。 -type openAIResponsesToolSchemaNullType struct { - offset int - length int +var errOpenAIResponsesToolSchemaLimit = errors.New("OpenAI Responses tool schema safety limit exceeded") + +// shouldRepairOpenAIResponsesNullToolSchemaType reports whether the upstream +// path requires a concrete object type at a function tool's parameter root. +// This defect is shared by the OpenAI, Anthropic, and CN-compatible paths. +func shouldRepairOpenAIResponsesNullToolSchemaType(platform string) bool { + return platform == PlatformOpenAI || platform == PlatformAnthropic || IsCNProvider(platform) } -// sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的 -// tools[].parameters.type。 -// -// Codex Desktop 内置的 automation_update 工具会带 parameters.type = null, -// OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的 -// 502 upstream_error;该工具定义又会沉进多轮历史,导致之后每一轮继续失败并在 -// 账号池里反复重放同一份坏 Schema。 -// -// 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄 -// 客户端语义,因此保持原样。 -// -// 实现上先收集全部命中的绝对偏移,再一次性拼出新 body:逐个 sjson.SetBytes 每次 -// 都会重扫并全量拷贝整个文档,命中 N 处就是 N 次全量拷贝,而 /v1/responses 的 -// body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中。 +// shouldSanitizeOpenAIResponsesToolSchemaPatterns is intentionally narrower: +// regex lookaround rejection is an OpenAI-specific schema constraint. +func shouldSanitizeOpenAIResponsesToolSchemaPatterns(platform string) bool { + return platform == PlatformOpenAI +} + +func sanitizeOpenAIResponsesToolSchemasForPlatform(body []byte, platform string) ([]byte, bool, error) { + normalized := body + changed := false + if shouldRepairOpenAIResponsesNullToolSchemaType(platform) { + next, repaired, err := sanitizeOpenAIResponsesToolParameterTypes(normalized) + if err != nil { + return body, false, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", err) + } + if repaired { + normalized = next + changed = true + } + } + if shouldSanitizeOpenAIResponsesToolSchemaPatterns(platform) { + next, sanitized, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized) + if err != nil { + return body, false, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", err) + } + if sanitized { + normalized = next + changed = true + } + } + return normalized, changed, nil +} + +// sanitizeOpenAIResponsesToolSchemaPatterns removes only schema constraints +// containing regex lookaround, which OpenAI rejects. It deliberately does not +// descend into instance-valued keywords such as default, examples, const, or +// enum. +func sanitizeOpenAIResponsesToolSchemaPatterns(body []byte) ([]byte, bool, error) { + if len(body) == 0 || !openAIResponsesBodyMayContainRegexLookaround(body) { + return body, false, nil + } + return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{removeLookaroundPatterns: true}) +} + +// sanitizeOpenAIResponsesToolParameterTypes replaces an explicitly null type +// on a tool's parameter root with object. It also repairs the known Codex +// missing root type when it is unambiguously implied by an object-only +// oneOf/anyOf. Other missing types and nested property schemas are left +// unchanged. func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } - - hits := make([]openAIResponsesToolSchemaNullType, 0, 2) - collectOpenAIResponsesToolSchemaNullTypes(body, gjson.GetBytes(body, "tools"), 0, &hits) - if input := gjson.GetBytes(body, "input"); input.IsArray() { - input.ForEach(func(_, item gjson.Result) bool { - if item.IsObject() { - collectOpenAIResponsesToolSchemaNullTypes(body, item.Get("tools"), 0, &hits) - } - return true - }) - } - if len(hits) == 0 { - return body, false, nil - } - - // tools 与 input 在 body 里的先后顺序由客户端决定,收集顺序不保证单调。 - sort.Slice(hits, func(i, j int) bool { return hits[i].offset < hits[j].offset }) - - sanitized := make([]byte, 0, len(body)+len(hits)*len(openAIResponsesToolSchemaFallbackType)) - cursor := 0 - for _, hit := range hits { - // 收集阶段已逐个校验过区间,这里再挡一次重叠,保证拼接严格单调向前。 - if hit.offset < cursor { - continue - } - sanitized = append(sanitized, body[cursor:hit.offset]...) - sanitized = append(sanitized, openAIResponsesToolSchemaFallbackType...) - cursor = hit.offset + hit.length - } - sanitized = append(sanitized, body[cursor:]...) - return sanitized, true, nil -} - -// collectOpenAIResponsesToolSchemaNullTypes 收集一个 tools 数组里所有需要修正的 -// parameters.type 位置。不按 tool type 过滤:null 的 schema type 在 function、 -// custom 以及任何 hosted 工具上都同样非法。 -func collectOpenAIResponsesToolSchemaNullTypes( - body []byte, tools gjson.Result, depth int, hits *[]openAIResponsesToolSchemaNullType, -) { - if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() { - return - } - tools.ForEach(func(_, tool gjson.Result) bool { - if !tool.IsObject() { - return true - } - // Responses 形态用顶层 parameters,ChatCompletions 形态用 function.parameters, - // 两种都可能出现在 Responses 请求里(见 normalizeCodexTools)。 - for _, suffix := range []string{"parameters", "function.parameters"} { - params := tool.Get(suffix) - if !params.IsObject() { - continue - } - // gjson 用 Type==Null 同时表示「显式 null」和「路径不存在」,靠 Raw - // 区分:不存在时 Raw 为空串。 - if typ := params.Get("type"); typ.Type == gjson.Null && typ.Raw == openAIResponsesToolSchemaNullLiteral { - appendOpenAIResponsesToolSchemaNullType(body, typ, hits) - } - } - // 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如 - // input[234].tools[0].tools[3].parameters)。 - collectOpenAIResponsesToolSchemaNullTypes(body, tool.Get("tools"), depth+1, hits) - return true + return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{ + replaceNullParameterTypes: true, + injectObjectUnionRootObjectType: true, }) } -// appendOpenAIResponsesToolSchemaNullType 先校验 gjson 给出的偏移确实指向原始 -// body 上那段 null,再记录。gjson 对嵌套取值同样返回相对原始文档的绝对偏移,但 -// Index 为 0 表示未知;偏移不可用时跳过该处而不是猜位置——少修一个工具只是维持 -// 现状,拼错位置会损坏整个请求体。 -func appendOpenAIResponsesToolSchemaNullType( - body []byte, typ gjson.Result, hits *[]openAIResponsesToolSchemaNullType, -) { - end := typ.Index + len(typ.Raw) - if typ.Index <= 0 || end > len(body) { - return - } - if !bytes.Equal(body[typ.Index:end], []byte(typ.Raw)) { - return - } - *hits = append(*hits, openAIResponsesToolSchemaNullType{offset: typ.Index, length: len(typ.Raw)}) +func openAIResponsesBodyMayContainRegexLookaround(body []byte) bool { + // An opening parenthesis may be literal or encoded as JSON's canonical + // Unicode escape. Other lookaround characters may themselves be escaped, so + // the full decoded pattern is checked only after scoped parsing. + return bytes.Contains(body, []byte("(")) || bytes.Contains(body, []byte(`\u0028`)) +} + +func hasRegexLookaround(pattern string) bool { + return strings.Contains(pattern, "(?=") || strings.Contains(pattern, "(?!") || + strings.Contains(pattern, "(?<=") || strings.Contains(pattern, "(? openAIResponsesToolSchemaMaxBodySize { + return body, false, nil + } + if !utf8.Valid(body) { + return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid UTF-8") + } + p := openAIResponsesToolSchemaParser{body: body, options: options} + if err := p.parseValue(openAIResponsesToolSchemaDocument, false, 0); err != nil { + if errors.Is(err, errOpenAIResponsesToolSchemaLimit) { + return body, false, nil + } + return nil, false, err + } + p.skipWhitespace() + if p.pos != len(body) { + return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: trailing data at byte %d", p.pos) + } + if !json.Valid(body) { + return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid JSON") + } + if len(p.edits) == 0 { + return body, false, nil + } + return applyOpenAIResponsesToolSchemaEdits(body, p.edits) +} + +func (p *openAIResponsesToolSchemaParser) parseValue( + context openAIResponsesToolSchemaContext, schemaRoot bool, depth int, +) error { + if depth > openAIResponsesToolSchemaMaxDepth { + return errOpenAIResponsesToolSchemaLimit + } + p.skipWhitespace() + if p.pos >= len(p.body) { + return p.syntaxError("expected value") + } + if p.body[p.pos] == '{' { + switch context { + case openAIResponsesToolSchemaInput: + context = openAIResponsesToolSchemaInputItem + case openAIResponsesToolSchemaOrArray, openAIResponsesToolSchemaArray: + context = openAIResponsesToolSchema + } + } + if context == openAIResponsesToolSchemaInputItem && p.body[p.pos] == '{' { + probe := openAIResponsesToolSchemaParser{body: p.body, pos: p.pos} + if err := probe.parseValue(openAIResponsesToolSchemaTypeProbe, false, depth); err != nil { + return err + } + switch probe.probeType { + case "function", "custom", "tool_search", "namespace": + context = openAIResponsesToolSchemaTool + case "additional_tools": + context = openAIResponsesToolSchemaToolCarrier + default: + context = openAIResponsesToolSchemaSkip + } + } + switch p.body[p.pos] { + case '{': + return p.parseObject(context, schemaRoot, depth+1) + case '[': + return p.parseArray(context, depth+1) + case '"': + _, _, err := p.parseString() + return err + case 't': + return p.parseKeyword("true") + case 'f': + return p.parseKeyword("false") + case 'n': + return p.parseKeyword("null") + default: + return p.parseNumber() + } +} + +func (p *openAIResponsesToolSchemaParser) parseObject( + context openAIResponsesToolSchemaContext, schemaRoot bool, depth int, +) error { + p.pos++ + p.skipWhitespace() + if p.consume('}') { + return nil + } + previousComma := -1 + deleteRunStart := -1 + deleteRunPreviousComma := -1 + rootTypeCount := 0 + nullRootTypeStart := -1 + nullRootTypeEnd := -1 + parameterCount := 0 + parameterValueStart := -1 + parameterValueEnd := -1 + for { + p.skipWhitespace() + memberStart := p.pos + keyStart, keyEnd, err := p.parseString() + if err != nil { + return err + } + key := p.body[keyStart:keyEnd] + p.skipWhitespace() + if !p.consume(':') { + return p.syntaxError("expected colon") + } + p.skipWhitespace() + valueStart := p.pos + + childContext, childSchemaRoot := openAIResponsesToolSchemaChildContext(context, key) + if err := p.parseValue(childContext, childSchemaRoot, depth); err != nil { + return err + } + valueEnd := p.pos + p.skipWhitespace() + + deleteMember := false + if context == openAIResponsesToolSchemaTypeProbe && openAIResponsesJSONStringEquals(key, "type") { + if value, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok { + p.probeType = strings.TrimSpace(value) + } + } + if (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) && + openAIResponsesJSONStringEquals(key, "parameters") { + parameterCount++ + parameterValueStart = valueStart + parameterValueEnd = valueEnd + } + if context == openAIResponsesToolSchema && openAIResponsesJSONStringEquals(key, "pattern") && p.options.removeLookaroundPatterns { + if pattern, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok && hasRegexLookaround(pattern) { + deleteMember = true + } + } + if context == openAIResponsesToolSchema && schemaRoot && openAIResponsesJSONStringEquals(key, "type") { + // Duplicate JSON keys have parser-dependent effective values. Repair a + // root type only when it is unique rather than choosing a winner and + // potentially changing the client's effective schema. + rootTypeCount++ + if bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) { + nullRootTypeStart = valueStart + nullRootTypeEnd = valueEnd + } + } + + if p.pos >= len(p.body) { + return p.syntaxError("unterminated object") + } + if deleteMember && deleteRunStart < 0 { + deleteRunStart = memberStart + deleteRunPreviousComma = previousComma + } + if !deleteMember && deleteRunStart >= 0 { + // Remove through the delimiter after the deleted run, but retain the + // whitespace that originally preceded the next member. + if err := p.addEdit(deleteRunStart, previousComma+1, ""); err != nil { + return err + } + deleteRunStart = -1 + deleteRunPreviousComma = -1 + } + if p.body[p.pos] == ',' { + comma := p.pos + p.pos++ + previousComma = comma + continue + } + if p.body[p.pos] != '}' { + return p.syntaxError("expected comma or closing brace") + } + if deleteRunStart >= 0 { + start := deleteRunStart + if deleteRunPreviousComma >= 0 { + start = deleteRunPreviousComma + } + if err := p.addEdit(start, p.pos, ""); err != nil { + return err + } + } + if p.options.replaceNullParameterTypes && rootTypeCount == 1 && nullRootTypeStart >= 0 { + if err := p.addEdit(nullRootTypeStart, nullRootTypeEnd, openAIResponsesToolSchemaFallbackType); err != nil { + return err + } + } + if p.options.injectObjectUnionRootObjectType && + (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) && + parameterCount == 1 && parameterValueStart >= 0 { + if edit, ok := openAIMissingRootObjectUnionTypeEdit( + p.body[parameterValueStart:parameterValueEnd], parameterValueStart, + ); ok { + if err := p.addEdit(edit.start, edit.end, edit.replacement); err != nil { + return err + } + } + } + p.pos++ + return nil + } +} + +var ( + openAIResponsesJSONOneOfKey = []byte(`"oneOf"`) + openAIResponsesJSONAnyOfKey = []byte(`"anyOf"`) + openAIResponsesJSONEscapeNeedle = []byte{'\\'} +) + +func openAIMissingRootObjectUnionTypeEdit(raw []byte, absoluteStart int) (openAIResponsesToolSchemaEdit, bool) { + if len(raw) > openAIResponsesObjectUnionMaxSize { + return openAIResponsesToolSchemaEdit{}, false + } + if !bytes.Contains(raw, openAIResponsesJSONOneOfKey) && !bytes.Contains(raw, openAIResponsesJSONAnyOfKey) && + !bytes.Contains(raw, openAIResponsesJSONEscapeNeedle) { + return openAIResponsesToolSchemaEdit{}, false + } + var schema map[string]json.RawMessage + if len(raw) < 2 || raw[0] != '{' || json.Unmarshal(raw, &schema) != nil { + return openAIResponsesToolSchemaEdit{}, false + } + if _, hasType := schema["type"]; hasType || !openAIResponsesSchemaHasObjectOnlyUnion(schema, 0) { + return openAIResponsesToolSchemaEdit{}, false + } + return openAIResponsesToolSchemaEdit{ + start: absoluteStart + 1, + end: absoluteStart + 1, + replacement: `"type":"object",`, + }, true +} + +func openAIResponsesSchemaHasObjectOnlyUnion(schema map[string]json.RawMessage, depth int) bool { + if depth > openAIResponsesObjectUnionMaxDepth { + return false + } + found := false + for _, keyword := range []string{"oneOf", "anyOf"} { + raw, ok := schema[keyword] + if !ok { + continue + } + found = true + var branches []json.RawMessage + if json.Unmarshal(raw, &branches) != nil || len(branches) == 0 { + return false + } + for _, branch := range branches { + if !openAIResponsesSchemaIsObjectOnly(branch, depth+1) { + return false + } + } + } + return found +} + +func openAIResponsesSchemaIsObjectOnly(raw json.RawMessage, depth int) bool { + if depth > openAIResponsesObjectUnionMaxDepth { + return false + } + var schema map[string]json.RawMessage + if json.Unmarshal(raw, &schema) != nil { + return false + } + if rawType, ok := schema["type"]; ok { + var schemaType string + return json.Unmarshal(rawType, &schemaType) == nil && schemaType == "object" + } + return openAIResponsesSchemaHasObjectOnlyUnion(schema, depth+1) +} + +func (p *openAIResponsesToolSchemaParser) parseArray( + context openAIResponsesToolSchemaContext, depth int, +) error { + p.pos++ + p.skipWhitespace() + if p.consume(']') { + return nil + } + childContext := openAIResponsesToolSchemaSkip + switch context { + case openAIResponsesToolSchemaTools: + childContext = openAIResponsesToolSchemaTool + case openAIResponsesToolSchemaInput: + childContext = openAIResponsesToolSchemaInputItem + case openAIResponsesToolSchemaArray, openAIResponsesToolSchemaOrArray: + childContext = openAIResponsesToolSchema + } + for { + if err := p.parseValue(childContext, false, depth); err != nil { + return err + } + p.skipWhitespace() + if p.consume(',') { + continue + } + if !p.consume(']') { + return p.syntaxError("expected comma or closing bracket") + } + return nil + } +} + +func openAIResponsesToolSchemaChildContext( + context openAIResponsesToolSchemaContext, key []byte, +) (openAIResponsesToolSchemaContext, bool) { + switch context { + case openAIResponsesToolSchemaDocument: + switch { + case openAIResponsesJSONStringEquals(key, "tools"): + return openAIResponsesToolSchemaTools, false + case openAIResponsesJSONStringEquals(key, "input"): + return openAIResponsesToolSchemaInput, false + } + case openAIResponsesToolSchemaTool: + switch { + case openAIResponsesJSONStringEquals(key, "parameters"): + return openAIResponsesToolSchema, true + case openAIResponsesJSONStringEquals(key, "function"): + return openAIResponsesToolSchemaFunction, false + case openAIResponsesJSONStringEquals(key, "tools"): + return openAIResponsesToolSchemaTools, false + } + case openAIResponsesToolSchemaToolCarrier: + if openAIResponsesJSONStringEquals(key, "tools") { + return openAIResponsesToolSchemaTools, false + } + case openAIResponsesToolSchemaFunction: + switch { + case openAIResponsesJSONStringEquals(key, "parameters"): + return openAIResponsesToolSchema, true + case openAIResponsesJSONStringEquals(key, "tools"): + return openAIResponsesToolSchemaTools, false + } + case openAIResponsesToolSchema: + switch { + case openAIResponsesJSONStringMatchesAny(key, + "additionalProperties", "additionalItems", "contains", "not", "if", "then", "else", + "propertyNames", "unevaluatedProperties", "unevaluatedItems"): + return openAIResponsesToolSchema, false + case openAIResponsesJSONStringEquals(key, "items"): + return openAIResponsesToolSchemaOrArray, false + case openAIResponsesJSONStringMatchesAny(key, "anyOf", "oneOf", "allOf", "prefixItems"): + return openAIResponsesToolSchemaArray, false + case openAIResponsesJSONStringMatchesAny(key, + "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"): + return openAIResponsesToolSchemaMap, false + } + case openAIResponsesToolSchemaMap: + return openAIResponsesToolSchema, false + } + return openAIResponsesToolSchemaSkip, false +} + +func (p *openAIResponsesToolSchemaParser) parseString() (int, int, error) { + if p.pos >= len(p.body) || p.body[p.pos] != '"' { + return 0, 0, p.syntaxError("expected string") + } + start := p.pos + p.pos++ + for p.pos < len(p.body) { + switch p.body[p.pos] { + case '"': + p.pos++ + return start, p.pos, nil + case '\\': + p.pos++ + if p.pos >= len(p.body) { + return 0, 0, p.syntaxError("unterminated string escape") + } + switch p.body[p.pos] { + case 'u': + if p.pos+4 >= len(p.body) { + return 0, 0, p.syntaxError("short unicode escape") + } + for _, digit := range p.body[p.pos+1 : p.pos+5] { + if !isOpenAIResponsesJSONHexDigit(digit) { + return 0, 0, p.syntaxError("invalid unicode escape") + } + } + p.pos += 5 + continue + case '"', '\\', '/', 'b', 'f', 'n', 'r', 't': + p.pos++ + default: + return 0, 0, p.syntaxError("invalid string escape") + } + default: + if p.body[p.pos] < 0x20 { + return 0, 0, p.syntaxError("control character in string") + } + p.pos++ + } + } + return 0, 0, p.syntaxError("unterminated string") +} + +func isOpenAIResponsesJSONHexDigit(value byte) bool { + return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F' +} + +func (p *openAIResponsesToolSchemaParser) parseKeyword(keyword string) error { + if len(p.body)-p.pos < len(keyword) || string(p.body[p.pos:p.pos+len(keyword)]) != keyword { + return p.syntaxError("invalid literal") + } + p.pos += len(keyword) + return nil +} + +func (p *openAIResponsesToolSchemaParser) parseNumber() error { + start := p.pos + if p.consume('-') && p.pos >= len(p.body) { + return p.syntaxError("invalid number") + } + if p.consume('0') { + if p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' { + return p.syntaxError("invalid leading zero") + } + } else { + if p.pos >= len(p.body) || p.body[p.pos] < '1' || p.body[p.pos] > '9' { + return p.syntaxError("invalid number") + } + for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' { + p.pos++ + } + } + if p.consume('.') { + fractionStart := p.pos + for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' { + p.pos++ + } + if p.pos == fractionStart { + return p.syntaxError("invalid fraction") + } + } + if p.pos < len(p.body) && (p.body[p.pos] == 'e' || p.body[p.pos] == 'E') { + p.pos++ + if p.pos < len(p.body) && (p.body[p.pos] == '+' || p.body[p.pos] == '-') { + p.pos++ + } + exponentStart := p.pos + for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' { + p.pos++ + } + if p.pos == exponentStart { + return p.syntaxError("invalid exponent") + } + } + if p.pos == start { + return p.syntaxError("invalid value") + } + return nil +} + +func (p *openAIResponsesToolSchemaParser) addEdit(start, end int, replacement string) error { + if start < 0 || end < start || end > len(p.body) { + return p.syntaxError("invalid patch span") + } + if len(p.edits) >= openAIResponsesToolSchemaMaxEdits { + return errOpenAIResponsesToolSchemaLimit + } + p.edits = append(p.edits, openAIResponsesToolSchemaEdit{start: start, end: end, replacement: replacement}) + return nil +} + +func (p *openAIResponsesToolSchemaParser) skipWhitespace() { + for p.pos < len(p.body) { + switch p.body[p.pos] { + case ' ', '\t', '\n', '\r': + p.pos++ + default: + return + } + } +} + +func (p *openAIResponsesToolSchemaParser) consume(want byte) bool { + if p.pos < len(p.body) && p.body[p.pos] == want { + p.pos++ + return true + } + return false +} + +func (p *openAIResponsesToolSchemaParser) syntaxError(message string) error { + return fmt.Errorf("sanitize OpenAI Responses tool schemas: %s at byte %d", message, p.pos) +} + +func decodeOpenAIResponsesJSONString(raw []byte) (string, error) { + decoded, err := strconv.Unquote(string(raw)) + if err != nil { + return "", err + } + return decoded, nil +} + +func openAIResponsesJSONStringEquals(raw []byte, want string) bool { + if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return false + } + if !bytes.Contains(raw, []byte{'\\'}) { + return bytes.Equal(raw[1:len(raw)-1], []byte(want)) + } + decoded, err := decodeOpenAIResponsesJSONString(raw) + return err == nil && decoded == want +} + +func openAIResponsesJSONStringMatchesAny(raw []byte, candidates ...string) bool { + for _, candidate := range candidates { + if openAIResponsesJSONStringEquals(raw, candidate) { + return true + } + } + return false +} + +func decodeOpenAIResponsesJSONStringValue(raw []byte) (string, bool) { + if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return "", false + } + decoded, err := decodeOpenAIResponsesJSONString(raw) + return decoded, err == nil +} + +func applyOpenAIResponsesToolSchemaEdits( + body []byte, edits []openAIResponsesToolSchemaEdit, +) ([]byte, bool, error) { + sort.Slice(edits, func(i, j int) bool { + if edits[i].start != edits[j].start { + return edits[i].start < edits[j].start + } + return edits[i].end < edits[j].end + }) + + merged := edits[:0] + for _, edit := range edits { + if len(merged) == 0 || edit.start >= merged[len(merged)-1].end { + merged = append(merged, edit) + continue + } + previous := &merged[len(merged)-1] + if previous.replacement != "" || edit.replacement != "" { + return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: overlapping patches") + } + if edit.end > previous.end { + previous.end = edit.end + } + } + + delta := 0 + for _, edit := range merged { + delta += len(edit.replacement) - (edit.end - edit.start) + } + sanitized := make([]byte, 0, len(body)+delta) + cursor := 0 + for _, edit := range merged { + sanitized = append(sanitized, body[cursor:edit.start]...) + sanitized = append(sanitized, edit.replacement...) + cursor = edit.end + } + sanitized = append(sanitized, body[cursor:]...) + if !json.Valid(sanitized) { + return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: patch produced invalid JSON") + } + return sanitized, true, nil } diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index 2dbc7fb600..e899e17098 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strconv" "testing" "github.com/stretchr/testify/require" @@ -62,6 +63,59 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *tes require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists()) } +func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectOnlyRootUnion(t *testing.T) { + body := []byte(`{"tools":[ + {"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"}}},{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}]}}, + {"type":"function","name":"codex_app__automation_update","parameters":{"anyOf":[{"type":"object"}]}}, + {"type":"function","name":"unrelated_tool","parameters":{"oneOf":[{"type":"object"}]}} + ]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + for i := 0; i < 3; i++ { + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools."+strconv.Itoa(i)+".parameters.type").String()) + } + require.Equal(t, "string", gjson.GetBytes(sanitized, "tools.0.parameters.oneOf.0.properties.id.type").String()) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedUnionKeyword(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"other","parameters":{"one\u004ff":[{"type":"object"}]}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String()) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectUnionMissingTypeSafetyBoundary(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "mixed union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"string"}]}}]}`}, + {name: "unproven branch", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"properties":{}}]}}]}`}, + {name: "empty union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[]}}]}`}, + {name: "existing null handled by existing repair", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"oneOf":[{"type":"object"}]}}]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tt.body)) + require.NoError(t, err) + if tt.name == "existing null handled by existing repair" { + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String()) + return + } + require.False(t, changed) + require.Equal(t, tt.body, string(sanitized)) + }) + } +} + // 多轮历史:工具定义沉进 input 后,upstream 报错路径形如 // input[N].tools[i].tools[j].parameters,两层都要修。 func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) { @@ -71,6 +125,7 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing { "type": "additional_tools", "role": "developer", + "parameters": {"type": null}, "tools": [ { "type": "namespace", @@ -92,6 +147,7 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing require.True(t, changed) require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.1.parameters.type").String()) require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.1.parameters.type").String()) + require.Equal(t, "null", gjson.GetBytes(sanitized, "input.1.parameters.type").Raw) // 原本合法的兄弟条目保持不变。 require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String()) require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String()) @@ -110,6 +166,134 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testi require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String()) } +func TestSanitizeOpenAIResponsesToolParameterTypes_InputItemShapes(t *testing.T) { + body := []byte(`{"input":[{"parameters":{"type":null},"name":"direct","type":"function"},{"function":{"name":"wrapped","parameters":{"type":null}},"type":"function"}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "input.0.parameters.type").String()) + require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.function.parameters.type").String()) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotTreatMessagesAsTools(t *testing.T) { + body := []byte(`{"input":[{"type":"message","role":"user","parameters":{"type":null},"function":{"parameters":{"type":null}},"metadata":{"parameters":{"type":null}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(sanitized)) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_PreservesUntouchedBytes(t *testing.T) { + body := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : null},\"name\":\"x\"}], \"a\":-0.00\n}") + want := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : \"object\"},\"name\":\"x\"}], \"a\":-0.00\n}") + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, string(want), string(sanitized)) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedKey(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","parameters":{"ty\u0070e":null}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, `{"tools":[{"type":"function","parameters":{"ty\u0070e":"object"}}]}`, string(sanitized)) +} + +func TestSanitizeOpenAIResponsesToolParameterTypes_DuplicateTypeIsAmbiguousNoOp(t *testing.T) { + for _, body := range [][]byte{ + []byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":"string"}}]}`), + []byte(`{"tools":[{"type":"function","parameters":{"type":"string","ty\u0070e":null}}]}`), + []byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":null}}]}`), + } { + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(sanitized)) + } +} + +func TestSanitizeOpenAIResponsesToolSchemas_InvalidAndTrailingJSON(t *testing.T) { + for _, tc := range []struct { + name string + body []byte + call func([]byte) ([]byte, bool, error) + }{ + {"null_type_trailing", []byte(`{"tools":[{"parameters":{"type":null}}]} trailing`), sanitizeOpenAIResponsesToolParameterTypes}, + {"pattern_trailing", []byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}]} trailing`), sanitizeOpenAIResponsesToolSchemaPatterns}, + {"invalid_utf8", append([]byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}],"bad":"`), 0xff, '"', '}'), sanitizeOpenAIResponsesToolSchemaPatterns}, + } { + t.Run(tc.name, func(t *testing.T) { + _, changed, err := tc.call(tc.body) + require.Error(t, err) + require.False(t, changed) + }) + } +} + +func TestOpenAIResponsesToolSchemaCapabilities_PlatformBoundary(t *testing.T) { + tests := []struct { + platform string + repairNullType bool + removeLookaround bool + }{ + {PlatformOpenAI, true, true}, + {PlatformAnthropic, true, false}, + {PlatformKimi, true, false}, + {PlatformZhipu, true, false}, + {PlatformDeepseek, true, false}, + {PlatformGrok, false, false}, + {PlatformGemini, false, false}, + {PlatformAntigravity, false, false}, + {PlatformComposite, false, false}, + {"", false, false}, + } + for _, tt := range tests { + t.Run(tt.platform, func(t *testing.T) { + require.Equal(t, tt.repairNullType, shouldRepairOpenAIResponsesNullToolSchemaType(tt.platform)) + require.Equal(t, tt.removeLookaround, shouldSanitizeOpenAIResponsesToolSchemaPatterns(tt.platform)) + }) + } +} + +func TestSanitizeOpenAIResponsesToolSchemasForPlatform_ReplayBoundary(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","parameters":{"type":null,"properties":{"query":{"type":"string","pattern":"(?=keep)"}}}}]}`) + + // A malformed tool definition may be replayed after account failover. Every + // compatible account must repair it, while non-OpenAI providers retain their + // supported regex semantics. + for _, platform := range []string{PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek} { + t.Run(platform, func(t *testing.T) { + for attempt := 0; attempt < 2; attempt++ { + normalized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, platform) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String()) + require.Equal(t, "(?=keep)", gjson.GetBytes(normalized, "tools.0.parameters.properties.query.pattern").String()) + } + }) + } + + openAI, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, PlatformOpenAI) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(openAI, "tools.0.parameters.type").String()) + require.False(t, gjson.GetBytes(openAI, "tools.0.parameters.properties.query.pattern").Exists()) + + unsupported, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, PlatformGrok) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(unsupported)) +} + // 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。 func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) { body := []byte(`{"tools":[ @@ -201,6 +385,175 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotMutateInputBody(t *tes require.NotEqual(t, string(original), string(sanitized)) } +func TestSanitizeOpenAIResponsesToolSchemaPatterns_RemovesLookaroundOnly(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"search","parameters":{"type":"object","properties":{"q":{"type":"string","pattern":"^(?=.*foo)[a-z]+$"},"id":{"type":"string","pattern":"^[a-z]+$"},"z":{"type":"string","pattern":"(? 0 { + body = append(body, ',') + } + body = append(body, []byte(`{"type":"function","name":"automation_update","parameters":{"properties":{},"type":null}}`)...) + } + body = append(body, []byte(`]}`)...) + b.ReportAllocs() + b.SetBytes(int64(len(body))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(body) + } +} diff --git a/backend/internal/service/openai_routing_hint.go b/backend/internal/service/openai_routing_hint.go index 0a1cc928cd..85fd93314d 100644 --- a/backend/internal/service/openai_routing_hint.go +++ b/backend/internal/service/openai_routing_hint.go @@ -26,7 +26,7 @@ func setOpenAICodexRoutingHint(headers http.Header, account *Account, model stri // through a caller- or account-override-supplied hint. http.Header.Del only // removes the canonical map key; inbound maps can contain raw lowercase keys. deleteOpenAIHeaderEqualFold(headers, openAICodexRoutingHintHeader) - if account == nil || !account.IsOpenAIOAuth() { + if account == nil || !account.IsOpenAIOAuthLike() { return } diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go new file mode 100644 index 0000000000..8f2f2bfcee --- /dev/null +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -0,0 +1,247 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestIsOpenAIOAuthLike(t *testing.T) { + tests := []struct { + name string + account *Account + want bool + codex bool + }{ + {name: "openai_oauth", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, want: true, codex: true}, + {name: "openai_setup_token", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, want: true, codex: true}, + {name: "openai_api_key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: false, codex: false}, + {name: "anthropic_setup_token", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, want: false, codex: false}, + {name: "grok_setup_token", account: &Account{Platform: PlatformGrok, Type: AccountTypeSetupToken}, want: false, codex: false}, + {name: "nil", account: nil, want: false, codex: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.account.IsOpenAIOAuthLike()) + require.Equal(t, tt.codex, tt.account.UsesOpenAICodexProtocol()) + }) + } +} + +func TestOpenAIGatewayServiceGetAccessTokenSetupToken(t *testing.T) { + svc := &OpenAIGatewayService{openAITokenProvider: &OpenAITokenProvider{}} + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Credentials: map[string]any{"access_token": "setup-token-value"}, + } + + token, tokenType, err := svc.GetAccessToken(context.Background(), account) + require.NoError(t, err) + require.Equal(t, "setup-token-value", token) + require.Equal(t, "oauth", tokenType) + + delete(account.Credentials, "access_token") + _, _, err = svc.GetAccessToken(context.Background(), account) + require.EqualError(t, err, "access_token not found in credentials") + + for _, platform := range []string{PlatformAnthropic, PlatformGrok} { + foreign := &Account{ + Platform: platform, + Type: AccountTypeSetupToken, + Credentials: map[string]any{"access_token": "foreign-token"}, + } + _, _, err = svc.GetAccessToken(context.Background(), foreign) + require.EqualError(t, err, "unsupported account type: setup-token") + } +} + +func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 73, + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Credentials: map[string]any{"access_token": "setup-token"}, + } + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesGenerationsEndpoint, + Model: "gpt-image-2", + Prompt: "draw a square", + N: 1, + ResponseFormat: "b64_json", + } + + result, err := svc.ForwardImages(context.Background(), c, account, nil, parsed, "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.True(t, failoverErr.RetryableOnSameAccount) + require.False(t, failoverErr.SameAccountRetryDeadline.IsZero()) + require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/responses") +} + +func TestOpenAISetupTokenWSCompatibility(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{}`)) + c.Request.Header.Set("session_id", "session-one") + c.Set("api_key", &APIKey{ID: 17}) + + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Credentials: map[string]any{ + "access_token": "setup-token-value", + "chatgpt_account_id": "chatgpt-setup", + }, + } + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + wsURL, err := svc.buildOpenAIResponsesWSURL(account) + require.NoError(t, err) + require.Equal(t, "wss://chatgpt.com/backend-api/codex/responses", wsURL) + foreignURL, err := svc.buildOpenAIResponsesWSURL(&Account{Platform: PlatformGrok, Type: AccountTypeSetupToken}) + require.NoError(t, err) + require.Equal(t, "wss://api.openai.com/v1/responses", foreignURL) + + headers, session, err := svc.buildOpenAIWSHeaders( + context.Background(), c, account, "setup-token-value", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, "", "", "", "gpt-5.1-codex", "", + ) + require.NoError(t, err) + require.Equal(t, "Bearer setup-token-value", headers.Get("authorization")) + require.Equal(t, "chatgpt-setup", headers.Get("chatgpt-account-id")) + require.NotEmpty(t, headers.Get("originator")) + require.Equal(t, "session-one", session.SessionID) + require.NotEqual(t, session.SessionID, headers.Get("session_id")) + + payload := svc.buildOpenAIWSCreatePayload(map[string]any{"store": true}, account) + require.Equal(t, false, payload["store"]) +} + +func TestOpenAISetupTokenChatCompletionsUsesCodexTransform(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"setup instructions"},{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop after request capture"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := openAISetupTokenCompatAccount(71) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4") + + require.Error(t, err) + require.Nil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) + require.Equal(t, "Bearer setup-token-value", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "chatgpt-setup", upstream.lastReq.Header.Get("chatgpt-account-id")) + require.NotEmpty(t, upstream.lastReq.Header.Get("originator")) + require.Equal(t, "setup instructions", gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int()) + require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String()) + require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) +} + +func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstResp := openAICompatSSECompletedResponse("resp_setup_first", "gpt-5.4") + firstResp.Header.Set("x-codex-turn-state", "turn_state_setup") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + firstResp, + openAICompatSSECompletedResponse("resp_setup_second", "gpt-5.4"), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + } + account := openAISetupTokenCompatAccount(72) + + messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3) + for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ { + messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`) + } + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`) + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4") + + require.NoError(t, err) + require.NotNil(t, firstResult) + require.True(t, isOpenAICompatMessagesBridgeContext(firstCtx)) + require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.bodies[0], "input.#").Int()) + require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[0], "input.0.role").String()) + require.Contains(t, gjson.GetBytes(upstream.bodies[0], "input.0.content.0.text").String(), openAICompatClaudeCodeTodoGuardMarker) + require.Equal(t, "message-00", gjson.GetBytes(upstream.bodies[0], "input.1.content.0.text").String()) + require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) + require.Equal(t, chatgptCodexURL, upstream.requests[0].URL.String()) + require.Equal(t, "Bearer setup-token-value", upstream.requests[0].Header.Get("Authorization")) + require.Equal(t, "chatgpt-setup", upstream.requests[0].Header.Get("chatgpt-account-id")) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex-tui") + require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"next"}],"stream":false}`) + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4") + + require.NoError(t, err) + require.NotNil(t, secondResult) + require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx)) + require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui") +} + +func openAISetupTokenCompatAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "openai-setup-token", + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "setup-token-value", + "chatgpt_account_id": "chatgpt-setup", + }, + } +} diff --git a/backend/internal/service/openai_sse_data.go b/backend/internal/service/openai_sse_data.go index 61b813b601..3fdb70f8f4 100644 --- a/backend/internal/service/openai_sse_data.go +++ b/backend/internal/service/openai_sse_data.go @@ -43,6 +43,37 @@ func forEachOpenAISSEDataPayload(body string, fn func([]byte)) { acc.Flush(fn) } +func forEachOpenAISSEFrame(body string, fn func(string, []byte)) { + if fn == nil || strings.TrimSpace(body) == "" { + return + } + var parser openAICompatSSEFrameParser + emit := func(frame openAICompatSSEFrame, ok bool) { + if !ok { + return + } + emitData := func(value string) { + value = strings.TrimSpace(value) + if value == "" || value == "[DONE]" { + return + } + data := []byte(value) + fn(effectiveOpenAISSEEventType(data, frame.EventType), data) + } + if gjson.Valid(frame.Data) { + emitData(frame.Data) + return + } + for _, value := range strings.Split(frame.Data, "\n") { + emitData(value) + } + } + for _, line := range strings.Split(body, "\n") { + emit(parser.AddLine(strings.TrimRight(line, "\r"))) + } + emit(parser.Finish()) +} + func emitOpenAISSEDataPayloads(lines []string, fn func([]byte)) { if fn == nil || len(lines) == 0 { return diff --git a/backend/internal/service/openai_team_linked_error.go b/backend/internal/service/openai_team_linked_error.go new file mode 100644 index 0000000000..2225e59a5b --- /dev/null +++ b/backend/internal/service/openai_team_linked_error.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/tidwall/gjson" +) + +const ( + openAITeamLinkedErrorDedupTTL = 60 * time.Second + openAITeamLinkedErrorFanoutTimeout = 30 * time.Second + openAITeamLinkedErrorBlockReason = "team_linked_error" +) + +// maybeHandleOpenAITeamLinkedError 在 OpenAI OAuth 账户收到 402 deactivated_workspace +// (ChatGPT Team 工作区被停用)时,把同一 Team(credentials.chatgpt_account_id 相同) +// 的其余 active 账户一并置为 error 并立即熔断。触发账户自身不在 fan-out 范围内, +// 仍由常规 402 处理标记。 +func (s *RateLimitService) maybeHandleOpenAITeamLinkedError(ctx context.Context, account *Account, statusCode int, responseBody []byte) { + if s == nil || s.accountRepo == nil || statusCode != http.StatusPaymentRequired || !isOpenAIOAuthAccount(account) { + return + } + if gjson.GetBytes(responseBody, "detail.code").String() != "deactivated_workspace" { + return + } + teamID := strings.TrimSpace(account.GetChatGPTAccountID()) + if teamID == "" { + return + } + if !s.markOpenAITeamLinkedFired(teamID) { + return + } + // 上游报错场景请求 ctx 往往已被取消,落库需要独立生命周期。 + opCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAITeamLinkedErrorFanoutTimeout) + defer cancel() + + accounts, err := s.accountRepo.ListByPlatform(opCtx, PlatformOpenAI) + if err != nil { + slog.Warn("openai_team_linked_error_list_failed", "trigger_account_id", account.ID, "error", err) + return + } + var targets []*Account + for i := range accounts { + acc := &accounts[i] + if acc.ID == account.ID || acc.IsShadow() || strings.TrimSpace(acc.GetChatGPTAccountID()) != teamID { + continue + } + targets = append(targets, acc) + } + if len(targets) == 0 { + return + } + // 先全部进程内熔断(微秒级生效),再逐个落库,避免后面的账户等待前面的 DB 写入。 + for _, acc := range targets { + s.notifyAccountSchedulingBlocked(acc, time.Time{}, openAITeamLinkedErrorBlockReason) + } + errorMsg := fmt.Sprintf("Workspace deactivated (402): team-linked error triggered by account #%d", account.ID) + marked := 0 + for _, acc := range targets { + // 单账户写入失败不中断其余账户;进程内熔断已先行,且该账户仍为 active, + // 下一个 402 在去重 TTL 过期后会重新触发 fan-out。 + if err := s.accountRepo.SetError(opCtx, acc.ID, errorMsg); err != nil { + slog.Warn("openai_team_linked_error_set_error_failed", "account_id", acc.ID, "error", err) + continue + } + marked++ + } + slog.Warn("openai_team_linked_error_fanout", + "trigger_account_id", account.ID, + "chatgpt_account_id", teamID, + "affected", marked, + "targets", len(targets), + ) +} + +// markOpenAITeamLinkedFired 以 teamID 为键做进程内去重:TTL 内同一 Team 只允许一次 fan-out。 +func (s *RateLimitService) markOpenAITeamLinkedFired(teamID string) bool { + now := time.Now() + s.openaiTeamLinkedMu.Lock() + defer s.openaiTeamLinkedMu.Unlock() + if expiry, ok := s.openaiTeamLinkedRecent[teamID]; ok && expiry.After(now) { + return false + } + if s.openaiTeamLinkedRecent == nil { + s.openaiTeamLinkedRecent = make(map[string]time.Time) + } + for k, v := range s.openaiTeamLinkedRecent { + if !v.After(now) { + delete(s.openaiTeamLinkedRecent, k) + } + } + s.openaiTeamLinkedRecent[teamID] = now.Add(openAITeamLinkedErrorDedupTTL) + return true +} diff --git a/backend/internal/service/openai_tool_continuation.go b/backend/internal/service/openai_tool_continuation.go index a507213701..3b2356c971 100644 --- a/backend/internal/service/openai_tool_continuation.go +++ b/backend/internal/service/openai_tool_continuation.go @@ -235,16 +235,16 @@ func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContex return coverage } input := parseRawJSONView(body).Get("input") - if !input.IsArray() { + if !input.IsArray() && !input.IsObject() { return coverage } missingCallID := false var outputCallIDs map[string]struct{} var contextIDs map[string]struct{} - input.ForEach(func(_, item gjson.Result) bool { + analyzeItem := func(item gjson.Result) { if !item.IsObject() { - return true + return } itemType := item.Get("type").String() switch { @@ -253,7 +253,7 @@ func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContex callID := strings.TrimSpace(item.Get("call_id").String()) if callID == "" { missingCallID = true - return true + return } if outputCallIDs == nil { outputCallIDs = make(map[string]struct{}) @@ -262,7 +262,7 @@ func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContex case isCodexToolCallContextItemType(itemType): callID := strings.TrimSpace(item.Get("call_id").String()) if callID == "" { - return true + return } if contextIDs == nil { contextIDs = make(map[string]struct{}) @@ -271,15 +271,22 @@ func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContex case itemType == "item_reference": idValue := strings.TrimSpace(item.Get("id").String()) if idValue == "" { - return true + return } if contextIDs == nil { contextIDs = make(map[string]struct{}) } contextIDs[idValue] = struct{}{} } - return true - }) + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + analyzeItem(item) + return true + }) + } else { + analyzeItem(input) + } if !coverage.HasFunctionCallOutput || missingCallID { return coverage diff --git a/backend/internal/service/openai_tool_continuation_test.go b/backend/internal/service/openai_tool_continuation_test.go index 569d89eff0..460a659c2e 100644 --- a/backend/internal/service/openai_tool_continuation_test.go +++ b/backend/internal/service/openai_tool_continuation_test.go @@ -206,6 +206,14 @@ func TestAnalyzeToolCallOutputContextCoverageBytes(t *testing.T) { hasOutput: false, coversAllIDs: false, }, + { + name: "object_tool_output_requires_context_replay", + body: map[string]any{"input": map[string]any{ + "type": "custom_tool_call_output", "call_id": "call_a", + }}, + hasOutput: true, + coversAllIDs: false, + }, { name: "all_outputs_covered_by_context", body: map[string]any{"input": []any{ diff --git a/backend/internal/service/openai_upstream_client_error_test.go b/backend/internal/service/openai_upstream_client_error_test.go index b9f5b3795a..291c1c491e 100644 --- a/backend/internal/service/openai_upstream_client_error_test.go +++ b/backend/internal/service/openai_upstream_client_error_test.go @@ -164,6 +164,7 @@ func TestHandleErrorResponse_NonDeterministicStatusesKeepGeneric502(t *testing.T {"unprocessable", http.StatusUnprocessableEntity, `{"error":{"message":"Invalid schema for field messages"}}`, http.StatusBadGateway, "upstream_error", "Upstream request failed"}, // 401/402/403 是网关运营方的凭据/账单问题,必须继续对客户端屏蔽上游账号状态。 + // 403 的自由文本不能升级成 durable access-state typed failover;只有明确结构化 code 才可以。 {"unauthorized", http.StatusUnauthorized, `{"error":{"message":"Incorrect API key provided: sk-abc"}}`, http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"}, {"forbidden", http.StatusForbidden, `{"error":{"message":"Your account is deactivated"}}`, @@ -183,8 +184,11 @@ func TestHandleErrorResponse_NonDeterministicStatusesKeepGeneric502(t *testing.T newOpenAIUpstreamErrorResponse(tc.statusCode, tc.body), c, newOpenAIUpstreamErrorTestAccount(), nil, ) - require.Error(t, err) + if tc.name == "forbidden" { + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + } require.Equal(t, tc.wantStatus, rec.Code) require.Equal(t, tc.wantType, gjson.Get(rec.Body.String(), "error.type").String()) require.Equal(t, tc.wantMsg, gjson.Get(rec.Body.String(), "error.message").String()) diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 4093ca0b64..b0f486aa9a 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -120,7 +120,7 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co // Client disconnected: do NOT fail over to another account and do NOT evict // this one — the upstream never had a chance to exhibit a fault. - if errors.Is(err, context.Canceled) { + if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) { return err } @@ -129,6 +129,12 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co scheduleOllamaCloudUsageActivity(s.deferredService, account) } + // 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。 + var pluginErr *PluginTransportError + if errors.As(err, &pluginErr) && pluginErr.RequestSent { + return err + } + if classifyOpenAITransportError(err).Persistent { s.tempUnscheduleOpenAITransportError(ctx, account, safeErr) } diff --git a/backend/internal/service/openai_visible_ttft_test.go b/backend/internal/service/openai_visible_ttft_test.go index 8fd243c39e..4d811dfbb3 100644 --- a/backend/internal/service/openai_visible_ttft_test.go +++ b/backend/internal/service/openai_visible_ttft_test.go @@ -71,11 +71,38 @@ func TestOpenAIResponsesTTFTStartsAtCompletedImage(t *testing.T) { } } -func TestOpenAINativeProgressDisarmsTimeoutWithoutStartingTTFT(t *testing.T) { - result := runSyntheticVisibleTTFTStream(t, false, 1200*time.Millisecond, 1, - `{"type":"response.output_text.delta","delta":"test output"}`) - require.NotNil(t, result.firstTokenMs) - require.GreaterOrEqual(t, *result.firstTokenMs, 1100) +func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, + OpenAIFirstOutputTimeoutSeconds: 1, + }}} + reader, writer := io.Pipe() + writerDone := make(chan struct{}) + go func() { + defer close(writerDone) + defer func() { _ = writer.Close() }() + _, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n") + _, _ = io.WriteString(writer, "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_test\",\"type\":\"reasoning\",\"summary\":[]}}\n\n") + time.Sleep(1200 * time.Millisecond) + }() + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader} + account := &Account{ID: 1, Name: "account_test", Platform: PlatformOpenAI} + + _, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "test-model", "test-model") + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.SafeToFailoverAfterWrite) + require.Empty(t, recorder.Body.String()) + select { + case <-writerDone: + case <-time.After(time.Second): + t.Fatal("synthetic upstream writer did not exit") + } } func runSyntheticVisibleTTFTStream(t *testing.T, passthrough bool, visibleDelay time.Duration, timeoutSeconds int, visibleEvent string) *openaiStreamingResult { diff --git a/backend/internal/service/openai_ws_account_sticky_test.go b/backend/internal/service/openai_ws_account_sticky_test.go index 6fc44298a4..3d0035eddb 100644 --- a/backend/internal/service/openai_ws_account_sticky_test.go +++ b/backend/internal/service/openai_ws_account_sticky_test.go @@ -209,7 +209,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *test require.Nil(t, selection) } -func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored(t *testing.T) { +func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_APIKeyForceHTTPHit(t *testing.T) { ctx := context.Background() groupID := int64(23) account := Account{ @@ -239,7 +239,44 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_force_http", "gpt-5.1", nil, false) require.NoError(t, err) - require.Nil(t, selection, "force_http 场景应忽略 previous_response_id 粘连") + require.NotNil(t, selection, "API-key HTTP continuation must retain the key/project that created the response") + require.NotNil(t, selection.Account) + require.Equal(t, account.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_OAuthForceHTTPIgnored(t *testing.T) { + ctx := context.Background() + groupID := int64(23) + account := Account{ + ID: 12, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "openai_ws_force_http": true, + "responses_websockets_v2_enabled": true, + }, + } + cache := &stubGatewayCache{} + store := NewOpenAIWSStateStore(cache) + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + cache: cache, + cfg: newOpenAIWSV2TestConfig(), + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + openaiWSStateStore: store, + } + + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_oauth_force_http", account.ID, time.Hour)) + + selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_oauth_force_http", "gpt-5.1", nil, false) + require.NoError(t, err) + require.Nil(t, selection, "OAuth HTTP fallback cannot preserve WSv2 continuation state") } func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky(t *testing.T) { diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 51898060ac..0994446624 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -96,6 +96,42 @@ type openAIWSIngressTurnError struct { wroteDownstream bool } +type openAIWSCurrentTurnFailoverError struct { + cause error + retryPayload []byte +} + +func (e *openAIWSCurrentTurnFailoverError) Error() string { + if e == nil || e.cause == nil { + return "openai websocket current-turn failover" + } + return e.cause.Error() +} + +func (e *openAIWSCurrentTurnFailoverError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +func newOpenAIWSCurrentTurnFailoverError(cause error, retryPayload []byte) error { + return &openAIWSCurrentTurnFailoverError{ + cause: cause, + retryPayload: append([]byte(nil), retryPayload...), + } +} + +// OpenAIWSCurrentTurnRetryPayload returns an isolated copy of the payload that +// may be retried on a replacement account without replaying the first turn. +func OpenAIWSCurrentTurnRetryPayload(err error) ([]byte, bool) { + var retryErr *openAIWSCurrentTurnFailoverError + if !errors.As(err, &retryErr) || retryErr == nil { + return nil, false + } + return append([]byte(nil), retryErr.retryPayload...), true +} + func (e *openAIWSIngressTurnError) Error() string { if e == nil { return "" @@ -215,10 +251,13 @@ type OpenAIWSIngressHooks struct { // before channel or account mapping. Ingress modes preserve it for usage // attribution while MapRequestModel determines the upstream model. InitialRequestModel string + // InitialTurnStartedAt freezes when the first response.create was accepted. + InitialTurnStartedAt time.Time // MaxReasoningEffort limits explicit reasoning effort values for this WS session. MaxReasoningEffort string // ReasoningEffortMappings rewrites explicit effort values for this WS session. ReasoningEffortMappings []ReasoningEffortMapping + TurnStarted func(turn int, startedAt time.Time) BeforeTurn func(turn int) error BeforeRequest func(turn int, payload []byte, originalModel string) error // MapRequestModel resolves the current turn's client model to the model diff --git a/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go b/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go index 2622f7f201..945986c8c7 100644 --- a/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go +++ b/backend/internal/service/openai_ws_forwarder_hotpath_optimization_test.go @@ -37,6 +37,18 @@ func TestParseOpenAIWSResponseUsageFromCompletedEvent(t *testing.T) { require.Equal(t, 19, usage.InputTokens) require.Equal(t, 5, usage.OutputTokens) require.Equal(t, 4, usage.CacheReadInputTokens) + + parseOpenAIWSResponseUsageFromCompletedEvent( + []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), + usage, + ) + require.Equal(t, OpenAIUsage{InputTokens: 19, OutputTokens: 5, CacheReadInputTokens: 4}, *usage) + + parseOpenAIWSResponseUsageFromCompletedEvent( + []byte(`{"type":"response.failed","response":{"usage":{"input_tokens":3,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), + usage, + ) + require.Equal(t, OpenAIUsage{InputTokens: 3}, *usage) } func TestOpenAIWSEventShouldParseUsageTerminalEvents(t *testing.T) { @@ -54,7 +66,11 @@ func TestOpenAIWSEventShouldParseUsageTerminalEvents(t *testing.T) { require.True(t, openAIWSEventShouldParseUsage(" "+eventType+" "), eventType) } require.False(t, openAIWSEventShouldParseUsage("response.output_text.delta")) + require.True(t, openAIWSEventShouldParseUsage("response.output_text.done")) require.False(t, openAIWSEventShouldParseUsage("")) + require.False(t, openAIWSMessageShouldParseUsage("response.in_progress", []byte(`{"type":"response.in_progress"}`))) + require.True(t, openAIWSMessageShouldParseUsage("response.in_progress", []byte(`{"type":"response.in_progress","usage":{}}`))) + require.False(t, openAIWSMessageShouldParseUsage("response.output_text.delta", []byte(`{"type":"response.output_text.delta","usage":{}}`))) } func TestOpenAIWSErrorEventHelpers_ConsistentWithWrapper(t *testing.T) { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index ce7e8f0386..8dbb5b1abb 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -18,6 +18,28 @@ import ( "github.com/tidwall/sjson" ) +type openAIWSRejectedFieldRetryError struct { + body []byte + reason string +} + +func (e *openAIWSRejectedFieldRetryError) Error() string { + if e == nil || strings.TrimSpace(e.reason) == "" { + return "retry websocket turn after rejected field normalization" + } + return "retry websocket turn after rejected field normalization: " + e.reason +} + +func openAIWSRejectedFieldRetryHTTPStatus(message []byte) int { + for _, value := range gjson.GetManyBytes(message, "status", "status_code", "error.status", "error.status_code") { + status := int(value.Int()) + if status >= 100 && status <= 599 { + return status + } + } + return openAIWSErrorHTTPStatus(message) +} + func (s *OpenAIGatewayService) openAIWSIngressInterTurnIdleTimeout() time.Duration { if s == nil || s.cfg == nil || s.cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds <= 0 { return 0 @@ -48,7 +70,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( token string, firstClientMessage []byte, hooks *OpenAIWSIngressHooks, -) error { +) (returnErr error) { if s == nil { return errors.New("service is nil") } @@ -61,6 +83,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if account == nil { return errors.New("account is nil") } + // A handler may reuse the same gin context across account failover attempts. + // Never let an OAuth attempt's response aliases leak into the next account. + setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return err + } if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } @@ -74,8 +102,21 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } + // The handler normally owns this registration across retry attempts. Direct + // callers still get the same session-scoped preemption behavior here. + if preemptCtx, cleanupPreempt, armed := s.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, firstClientMessage); armed { + ctx = preemptCtx + defer cleanupPreempt() + defer func() { + if isOpenAIWSSessionPreempted(ctx) { + returnErr = errOpenAIWSSessionPreempted + } + }() + } + wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - forceHTTPBridge := account.Platform == PlatformGrok + forceHTTPBridge := account.Platform == PlatformGrok || + (s.pluginManager != nil && s.pluginManager.ShouldRouteOpenAIOAuth(account)) modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled ingressMode := OpenAIWSIngressModeCtxPool if modeRouterV2Enabled && !forceHTTPBridge { @@ -92,11 +133,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { return fmt.Errorf("websocket ingress requires ws_v2 transport, got=%s", wsDecision.Transport) } - // 注意:透传 relay 只回调 hooks.AfterTurn,没有 turn 起始回调, - // 因此下面这条路径永远不会触发 hooks.BeforeTurn——分组利润控制的 - // turn 级复核与 turn 级 pricingAt 冻结都不覆盖透传 ingress, - // 只有建连时的准入门生效。handler 侧据此把 turn 定价留作零值, - // 由 RecordUsage 回退到记录时刻(见 openAIWSTurnPricing 注释)。 + // 透传 relay 通过 TurnStarted 记录每个 turn 的开始时刻,但不触发 + // BeforeTurn;因此仍只有建连时的利润准入门,没有 turn 级复核。 + // handler 计费在 turn 定价未冻结时回退到对应的 turn 开始时刻。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, @@ -145,15 +184,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) type openAIWSClientPayload struct { - payloadRaw []byte - rawForHash []byte - promptCacheKey string - previousResponseID string - originalModel string - imageBillingModel string - imageSizeTier string - imageInputSize string - payloadBytes int + payloadRaw []byte + accountIdentitySourceRaw []byte + rawForHash []byte + promptCacheKey string + previousResponseID string + originalModel string + imageBillingModel string + imageSizeTier string + imageInputSize string + payloadBytes int } ingressSessionOriginalModel := "" @@ -222,6 +262,22 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = capped } } + responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) + if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr) + } else if compatibilityChanged { + normalized = compatibilityBody + } + if account.IsOpenAIOAuthLike() { + aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(normalized) + if aliasErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr) + } + updateCodexToolNameReverseForWSFrame(c, normalized, reverse) + if aliased { + normalized = aliasedBody + } + } originalModel := strings.TrimSpace(values[1].String()) modelMissing := originalModel == "" @@ -257,8 +313,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } - if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(normalized) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized) + accountIdentitySourceRaw := append([]byte(nil), normalized...) + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(normalized, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + normalized = accountScopedPayload + } + if responsesLite { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account) if liteErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( coderws.StatusPolicyViolation, @@ -281,7 +345,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) if codexBridgeEnabled { payloadMap := make(map[string]any) - if err := json.Unmarshal(normalized, &payloadMap); err != nil { + if err := decodeOpenAIJSONUseNumber(normalized, &payloadMap); err != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", err) } bridgeModified := false @@ -326,6 +390,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } + SetOpsUpstreamModel(c, upstreamModel) if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { if stripped, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(normalized); stripErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) @@ -398,21 +463,23 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ingressSessionOriginalModel = originalModel return openAIWSClientPayload{ - payloadRaw: normalized, - rawForHash: trimmed, - promptCacheKey: promptCacheKey, - previousResponseID: previousResponseID, - originalModel: originalModel, - imageBillingModel: imageBillingModel, - imageSizeTier: imageSizeTier, - imageInputSize: imageInputSize, - payloadBytes: len(normalized), + payloadRaw: normalized, + accountIdentitySourceRaw: accountIdentitySourceRaw, + rawForHash: trimmed, + promptCacheKey: promptCacheKey, + previousResponseID: previousResponseID, + originalModel: originalModel, + imageBillingModel: imageBillingModel, + imageSizeTier: imageSizeTier, + imageInputSize: imageInputSize, + payloadBytes: len(normalized), }, nil } writeClientMessage := func(message []byte) error { writeCtx, cancel := newOpenAIWSDownstreamWriteContext(ctx, hooks, s.openAIWSWriteTimeout()) defer cancel() + message = restoreCodexToolNamesFromContext(c, message) return clientConn.Write(writeCtx, coderws.MessageText, message) } @@ -495,6 +562,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( grokCacheSeedPayload := firstPayload.payloadRaw var bridgeReplayInput []json.RawMessage bridgeReplayInputExists := false + var bridgeAccountFailoverInput []json.RawMessage + bridgeAccountFailoverInputExists := false for turn := 1; ; turn++ { if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { @@ -511,7 +580,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } bridgePayloadRaw := currentBridgePayload.payloadRaw bridgePayloadBytes := currentBridgePayload.payloadBytes - needsBridgeReplay := currentBridgePayload.previousResponseID != "" || openAIWSRawPayloadHasToolCallOutput(currentBridgePayload.payloadRaw) + toolOutputCoverage := AnalyzeToolCallOutputContextCoverageBytes(currentBridgePayload.payloadRaw) + needsBridgeReplay := currentBridgePayload.previousResponseID != "" || + (toolOutputCoverage.HasFunctionCallOutput && !toolOutputCoverage.ContextCoversAllCallIDs) turnReplayInput, turnReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( bridgeReplayInput, bridgeReplayInputExists, @@ -521,6 +592,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if replayInputErr != nil { return fmt.Errorf("build websocket http bridge replay input: %w", replayInputErr) } + turnAccountFailoverInput, turnAccountFailoverInputExists, failoverInputErr := buildOpenAIWSReplayInputSequence( + bridgeAccountFailoverInput, + bridgeAccountFailoverInputExists, + currentBridgePayload.payloadRaw, + needsBridgeReplay, + ) + if failoverInputErr != nil { + return fmt.Errorf("build websocket account failover input: %w", failoverInputErr) + } if needsBridgeReplay && turnReplayInputExists { updatedPayload, setInputErr := setOpenAIWSPayloadInputSequence( currentBridgePayload.payloadRaw, @@ -569,10 +649,29 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( turn, writeClientMessage, ) + if bridgeErr != nil && isOpenAIWSSessionPreempted(ctx) { + return errOpenAIWSSessionPreempted + } if hooks != nil && hooks.AfterTurn != nil { hooks.AfterTurn(turn, result, bridgeErr) } if bridgeErr != nil { + var failoverErr *UpstreamFailoverError + if turn > 1 && errors.As(bridgeErr, &failoverErr) && failoverErr != nil { + retryPayload, retrySafe, retryPayloadErr := buildOpenAIWSCurrentTurnRetryPayload( + currentBridgePayload.accountIdentitySourceRaw, + turnAccountFailoverInput, + turnAccountFailoverInputExists, + currentBridgePayload.originalModel, + ) + if retryPayloadErr != nil { + return fmt.Errorf("build websocket current-turn failover payload: %w", retryPayloadErr) + } + if !retrySafe { + retryPayload = nil + } + return newOpenAIWSCurrentTurnFailoverError(bridgeErr, retryPayload) + } return bridgeErr } if result == nil { @@ -584,6 +683,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeReplayInput = append(bridgeReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) bridgeReplayInputExists = true } + bridgeAccountFailoverInput = cloneOpenAIWSRawMessages(turnAccountFailoverInput) + bridgeAccountFailoverInputExists = turnAccountFailoverInputExists + if len(result.wsAccountFailoverReplayInput) > 0 { + bridgeAccountFailoverInput = append( + bridgeAccountFailoverInput, + cloneOpenAIWSRawMessages(result.wsAccountFailoverReplayInput)..., + ) + bridgeAccountFailoverInputExists = true + } if bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)); bridgeTurnState != "" { turnState = bridgeTurnState if stateStore != nil && sessionHash != "" { @@ -597,6 +705,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } nextClientMessage, readErr := readClientMessage() if readErr != nil { + if isOpenAIWSSessionPreempted(ctx) { + return errOpenAIWSSessionPreempted + } if isOpenAIWSClientDisconnectError(readErr) { closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) logOpenAIWSModeInfo( @@ -724,6 +835,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return acquireTurnLease(turn, preferred, forcePreferredConn) } if acquireErr != nil { + if isOpenAIWSSessionPreempted(ctx) { + return nil, errOpenAIWSSessionPreempted + } canonicalModel := canonicalOpenAIAccountSchedulingModel(account, ingressSessionOriginalModel) s.handleOpenAIWSDialTransientFailure(ctx, account, canonicalModel, acquireErr) dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr) @@ -750,10 +864,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( var dialErr *openAIWSDialError if errors.As(acquireErr, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests { s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(acquireErr.Error())) - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseHeaders: cloneHeader(dialErr.ResponseHeaders), - } + return nil, s.newOpenAIWSRateLimitFailoverError(account, dialErr.ResponseHeaders, nil, acquireErr.Error()) } if errors.Is(acquireErr, errOpenAIWSPreferredConnUnavailable) { return nil, NewOpenAIWSClientCloseError( @@ -797,6 +908,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return lease, nil } + var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) { responseModelObserver := &upstreamResponseModelObserver{} if lease == nil { @@ -878,9 +990,32 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( lastEventType = eventType } if eventType == "error" { - canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) - s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage) + s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage) errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) + statusCode := openAIWSRejectedFieldRetryHTTPStatus(upstreamMessage) + if !wroteDownstream && statusCode == http.StatusBadRequest && rejectedFieldRetryState != nil { + retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody( + statusCode, + payload, + upstreamMessage, + ) + if retryErr != nil { + return nil, fmt.Errorf("normalize websocket rejected field retry: %w", retryErr) + } + if changed && rejectedFieldRetryState.Allow(retryBody) { + logOpenAIWSModeInfo( + "ingress_ws_rejected_field_retry account_id=%d turn=%d conn_id=%s reason=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(retryReason, openAIWSLogValueMaxLen), + ) + return nil, &openAIWSRejectedFieldRetryError{ + body: append([]byte(nil), retryBody...), + reason: retryReason, + } + } + } s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw) fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) @@ -941,11 +1076,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } if !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { lease.MarkBroken() - return nil, &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseBody: append([]byte(nil), upstreamMessage...), - ResponseHeaders: cloneHeader(lease.HandshakeHeaders()), - } + return nil, s.newOpenAIWSRateLimitFailoverError(account, lease.HandshakeHeaders(), upstreamMessage, errMsgRaw) } } isTokenEvent := isOpenAIWSTokenEvent(eventType) @@ -960,7 +1091,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ms := int(time.Since(turnStart).Milliseconds()) firstTokenMs = &ms } - if openAIWSEventShouldParseUsage(eventType) { + if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) } imageCounter.AddSSEData(upstreamMessage) @@ -977,7 +1108,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( }) } } - if !clientDisconnected { if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) { upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel) @@ -1009,11 +1139,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } else { wroteDownstream = true + markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage) } } if isTerminalEvent { - canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) - terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage) + terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage) // 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。 if clientDisconnected { lease.MarkBroken() @@ -1047,7 +1177,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( UpstreamModel: mappedModel, UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), - ServiceTier: extractOpenAIServiceTierFromBody(payload), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(payload)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), Stream: reqStream, OpenAIWSMode: true, @@ -1135,6 +1266,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( defer releaseSessionLease() turn := 1 + rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload) turnRetry := 0 turnPrevRecoveryTried := false lastTurnFinishedAt := time.Time{} @@ -1568,6 +1700,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize) if relayErr != nil { lastTurnClean = false + if isOpenAIWSSessionPreempted(ctx) { + sessionLease.MarkBroken() + return errOpenAIWSSessionPreempted + } + var rejectedFieldErr *openAIWSRejectedFieldRetryError + if errors.As(relayErr, &rejectedFieldErr) && rejectedFieldErr != nil && len(rejectedFieldErr.body) > 0 { + currentPayload = append([]byte(nil), rejectedFieldErr.body...) + currentPayloadBytes = len(currentPayload) + skipBeforeTurn = true + continue + } if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { continue } @@ -1631,6 +1774,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( nextClientMessage, readErr := readClientMessage() if readErr != nil { + if isOpenAIWSSessionPreempted(ctx) { + return errOpenAIWSSessionPreempted + } if isOpenAIWSClientDisconnectError(readErr) { closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr) logOpenAIWSModeInfo( @@ -1713,6 +1859,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentImageSizeTier = nextPayload.imageSizeTier currentImageInputSize = nextPayload.imageInputSize currentPayloadBytes = nextPayload.payloadBytes + rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload) storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) if !storeDisabled { unpinSessionConn(sessionConnID) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 6715a01ae4..b05a2efd10 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -732,7 +732,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat"}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","parallel_tool_calls":true,"sequence":900719925474099312345}`)) cancelWrite() require.NoError(t, err) @@ -750,6 +750,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge "stream":false, "previous_response_id":"resp_codex_image_bridge", "reasoning":{"effort":"high"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[ @@ -787,11 +788,23 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, coderws.MessageText, msgType) require.Equal(t, "resp_codex_image_function", gjson.GetBytes(message, "response.id").String()) - _ = clientConn.Close(coderws.StatusNormalClosure, "done") + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.5", + "parallel_tool_calls":"false", + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "tools":[{"type":"function","name":"shell"}] + }`)) + cancelWrite() + require.NoError(t, err) select { case serverErr := <-serverErrCh: - require.NoError(t, serverErr) + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, serverErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) + require.Contains(t, closeErr.Reason(), "parallel_tool_calls to be a boolean") case <-time.After(5 * time.Second): t.Fatal("等待 ingress websocket 结束超时") } @@ -803,6 +816,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String()) require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation") require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists()) + require.True(t, gjson.Get(nonLitePayload, "parallel_tool_calls").Bool()) + require.Equal(t, "900719925474099312345", gjson.Get(nonLitePayload, "sequence").Raw) litePayload := requestToJSONString(captureConn.writes[1]) require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists()) @@ -815,6 +830,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String()) require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String()) + require.True(t, gjson.Get(litePayload, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(litePayload, "parallel_tool_calls").Bool()) functionPayload := requestToJSONString(captureConn.writes[2]) require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists()) @@ -1200,6 +1217,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade "stream":false, "prompt_cache_key":"pcache_passthrough", "reasoning":{"effort":"medium","context":"current_turn"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[{"type":"message","role":"user","content":"hello"}], @@ -1224,7 +1242,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade t.Fatal("等待 passthrough websocket 结束超时") } - require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) require.Equal(t, "turn-state-1", captureDialer.lastHeaders.Get(openAIWSTurnStateHeader)) require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) require.Len(t, upstreamConn.writes, 1) @@ -1235,6 +1253,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String()) require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String()) + require.True(t, gjson.Get(forwarded, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(forwarded, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) { @@ -1341,7 +1361,11 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} + }`)) cancelWrite() require.NoError(t, err) @@ -1382,6 +1406,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe } require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游") + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index a13248e393..1f00d0dce6 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -809,6 +809,35 @@ func TestBuildOpenAIWSReplayInputSequence(t *testing.T) { require.Equal(t, "new", gjson.GetBytes(items[0], "text").String()) }) + t.Run("no_previous_response_id_custom_tool_history_does_not_accumulate", func(t *testing.T) { + previousFull := []json.RawMessage{ + json.RawMessage(`{"type":"input_text","text":"stale"}`), + json.RawMessage(`{"type":"custom_tool_call","id":"stale_item","call_id":"stale_call","name":"exec","input":"stale"}`), + } + currentPayload := []byte(`{"input":[ + {"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}, + {"type":"custom_tool_call_output","call_id":"call_1","output":"/tmp"}, + {"type":"input_text","text":"continue"} + ]}`) + + for range 3 { + items, exists, err := buildOpenAIWSReplayInputSequence( + previousFull, + true, + currentPayload, + false, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 3) + require.Equal(t, "custom_tool_call", gjson.GetBytes(items[0], "type").String()) + require.Equal(t, "call_1", gjson.GetBytes(items[0], "call_id").String()) + require.Equal(t, "custom_tool_call_output", gjson.GetBytes(items[1], "type").String()) + require.Equal(t, "call_1", gjson.GetBytes(items[1], "call_id").String()) + previousFull = append(items, json.RawMessage(`{"type":"custom_tool_call","id":"replayed_item","call_id":"replayed_call","name":"exec","input":"ignored"}`)) + } + }) + t.Run("previous_response_id_delta_append", func(t *testing.T) { items, exists, err := buildOpenAIWSReplayInputSequence( lastFull, @@ -823,6 +852,91 @@ func TestBuildOpenAIWSReplayInputSequence(t *testing.T) { require.Equal(t, "world", gjson.GetBytes(items[1], "text").String()) }) + t.Run("previous_response_id_filters_orphan_historical_custom_tool_call", func(t *testing.T) { + previousFull := []json.RawMessage{ + json.RawMessage(`{"type":"input_text","text":"hello"}`), + json.RawMessage(`{"type":"custom_tool_call","id":"item_orphan","call_id":"call_orphan","name":"exec","input":"pwd"}`), + } + items, exists, err := buildOpenAIWSReplayInputSequence( + previousFull, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"role":"user","content":"continue"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + require.Equal(t, "hello", gjson.GetBytes(items[0], "text").String()) + require.Equal(t, "user", gjson.GetBytes(items[1], "role").String()) + }) + + t.Run("previous_response_id_preserves_paired_historical_function_call", func(t *testing.T) { + previousFull := []json.RawMessage{ + json.RawMessage(`{"type":"function_call","id":"item_1","call_id":"call_1","name":"lookup","arguments":"{}"}`), + json.RawMessage(`{"type":"function_call_output","call_id":"call_1","output":"ok"}`), + } + items, exists, err := buildOpenAIWSReplayInputSequence( + previousFull, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"role":"user","content":"continue"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 3) + require.Equal(t, "function_call", gjson.GetBytes(items[0], "type").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(items[1], "type").String()) + }) + + t.Run("previous_response_id_preserves_paired_historical_custom_tool_call", func(t *testing.T) { + previousFull := []json.RawMessage{ + json.RawMessage(`{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}`), + json.RawMessage(`{"type":"custom_tool_call_output","call_id":"call_1","output":"/tmp"}`), + } + items, exists, err := buildOpenAIWSReplayInputSequence( + previousFull, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"role":"user","content":"continue"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 3) + require.Equal(t, "custom_tool_call", gjson.GetBytes(items[0], "type").String()) + require.Equal(t, "custom_tool_call_output", gjson.GetBytes(items[1], "type").String()) + }) + + t.Run("item_reference_does_not_complete_historical_call", func(t *testing.T) { + previousFull := []json.RawMessage{ + json.RawMessage(`{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}`), + } + items, exists, err := buildOpenAIWSReplayInputSequence( + previousFull, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"type":"item_reference","id":"call_1"},{"role":"user","content":"continue"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + require.Equal(t, "item_reference", gjson.GetBytes(items[0], "type").String()) + require.Equal(t, "user", gjson.GetBytes(items[1], "role").String()) + }) + + t.Run("previous_response_id_preserves_current_orphan_custom_tool_call", func(t *testing.T) { + items, exists, err := buildOpenAIWSReplayInputSequence( + lastFull, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"type":"custom_tool_call","id":"item_live","call_id":"call_live","name":"exec","input":"pwd"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + require.Equal(t, "custom_tool_call", gjson.GetBytes(items[1], "type").String()) + require.Equal(t, "call_live", gjson.GetBytes(items[1], "call_id").String()) + }) + t.Run("previous_response_id_full_input_replace", func(t *testing.T) { items, exists, err := buildOpenAIWSReplayInputSequence( lastFull, diff --git a/backend/internal/service/openai_ws_forwarder_logutil.go b/backend/internal/service/openai_ws_forwarder_logutil.go index 04d1fb4a75..ddb37d8380 100644 --- a/backend/internal/service/openai_ws_forwarder_logutil.go +++ b/backend/internal/service/openai_ws_forwarder_logutil.go @@ -65,7 +65,10 @@ func resolveOpenAIWSSessionHeaders(c *gin.Context, promptCacheKey string) openAI ConversationSource: "none", } if c != nil && c.Request != nil { - if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" { + if sessionID := strings.TrimSpace(c.Request.Header.Get("session-id")); sessionID != "" { + resolution.SessionID = sessionID + resolution.SessionSource = "header_session-id" + } else if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" { resolution.SessionID = sessionID resolution.SessionSource = "header_session_id" } @@ -161,12 +164,15 @@ func openAIWSEventMayContainToolCalls(eventType string) bool { } func openAIWSEventShouldParseUsage(eventType string) bool { - switch strings.TrimSpace(eventType) { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + eventType = strings.TrimSpace(eventType) + if eventType == "error" || openAIStreamEventTypeIsTerminal(eventType) { return true - default: - return false } + return strings.HasPrefix(eventType, "response.") && !strings.HasSuffix(eventType, ".delta") +} + +func openAIWSMessageShouldParseUsage(eventType string, message []byte) bool { + return openAIWSEventShouldParseUsage(eventType) && bytes.Contains(message, []byte(`"usage"`)) } func parseOpenAIWSEventEnvelope(message []byte) (eventType string, responseID string, response gjson.Result) { @@ -193,11 +199,18 @@ func openAIWSMessageLikelyContainsToolCalls(message []byte) bool { } func parseOpenAIWSResponseUsageFromCompletedEvent(message []byte, usage *OpenAIUsage) { - if usage == nil || len(message) == 0 { + if usage == nil || len(message) == 0 || !bytes.Contains(message, []byte(`"usage"`)) { return } if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(message); ok { - *usage = parsedUsage + if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(message, "")) { + if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) { + return + } + *usage = parsedUsage + } else { + mergeOpenAIUsageNonZero(usage, parsedUsage) + } } } diff --git a/backend/internal/service/openai_ws_forwarder_logutil_test.go b/backend/internal/service/openai_ws_forwarder_logutil_test.go new file mode 100644 index 0000000000..eaf8b5f22b --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_logutil_test.go @@ -0,0 +1,37 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResolveOpenAIWSSessionHeadersPrefersCodexHyphenHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("session-id", "codex-session") + c.Request.Header.Set("session_id", "legacy-session") + + resolution := resolveOpenAIWSSessionHeaders(c, "prompt-cache") + + require.Equal(t, "codex-session", resolution.SessionID) + require.Equal(t, "header_session-id", resolution.SessionSource) +} + +func TestResolveOpenAIWSSessionHeadersFallsBackToLegacyHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("session_id", "legacy-session") + + resolution := resolveOpenAIWSSessionHeaders(c, "prompt-cache") + + require.Equal(t, "legacy-session", resolution.SessionID) + require.Equal(t, "header_session_id", resolution.SessionSource) +} diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index c53d9e8241..94738221a2 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -33,6 +33,12 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri switch account.Type { case AccountTypeOAuth: targetURL = chatgptCodexURL + case AccountTypeSetupToken: + if account.IsOpenAIOAuthLike() { + targetURL = chatgptCodexURL + } else { + targetURL = openaiPlatformAPIURL + } case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() if baseURL == "" { @@ -42,7 +48,7 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri if err != nil { return "", err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } default: targetURL = openaiPlatformAPIURL @@ -93,20 +99,32 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( headers.Add("x-codex-beta-features", value) } } - for _, name := range [...]string{"x-codex-window-id", "x-codex-installation-id"} { + for _, name := range [...]string{ + "x-codex-window-id", + "x-codex-installation-id", + "session-id", + "thread-id", + "x-client-request-id", + } { if value := c.Request.Header.Get(name); strings.TrimSpace(value) != "" { headers.Set(name, value) } } } + // 真实 Codex 的 WS 握手同样携带会话级 x-codex-beta-features + // (client.rs build_websocket_headers 复用 build_responses_headers), + // 客户端未声明时补成默认形态,与 HTTP 出站保持一致。放在客户端头拷贝 + // 之外:该头是账号/会话级属性,不依赖入站请求是否存在,也避免预热与 + // 实际请求因头差异落进不同的连接池兼容分桶。 + applyOpenAICodexBetaFeatures(c, account, headers) // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { apiKeyID := getAPIKeyIDFromContext(c) if sessionResolution.SessionID != "" { - headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID)) + headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID)) } if sessionResolution.ConversationID != "" { - headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID)) + headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID)) } } else { if sessionResolution.SessionID != "" { @@ -122,8 +140,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } + applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + applyStagedCodexFingerprintHeaders(c, account, headers) - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err) } @@ -148,11 +168,11 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } } if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - headers.Set("user-agent", codexCLIUserAgent) + headers.Set("user-agent", CodexCanonicalUserAgent()) } // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 - if account != nil && account.Type == AccountTypeOAuth { + if account != nil && account.UsesOpenAICodexProtocol() { enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account)) } @@ -188,7 +208,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any payload["type"] = "response.create" // OAuth 默认保持 store=false,避免误依赖服务端历史。 - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) { payload["store"] = false } return payload @@ -232,7 +252,7 @@ func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) } func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool { - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) { return true } if len(reqBody) == 0 { @@ -250,7 +270,7 @@ func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[stri } func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool { - if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) { + if account != nil && account.UsesOpenAICodexProtocol() && !s.isOpenAIWSStoreRecoveryAllowed(account) { return true } if len(reqBody) == 0 { @@ -343,7 +363,7 @@ func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string } var reqBody map[string]any - if unmarshalErr := json.Unmarshal(payload, &reqBody); unmarshalErr != nil { + if unmarshalErr := decodeOpenAIJSONUseNumber(payload, &reqBody); unmarshalErr != nil { return nil, err } reqBody["previous_response_id"] = normalizedPrevID @@ -437,7 +457,7 @@ func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) { return nil, errors.New("json is empty") } var decoded any - if err := json.Unmarshal(trimmed, &decoded); err != nil { + if err := decodeOpenAIJSONUseNumber(trimmed, &decoded); err != nil { return nil, err } return json.Marshal(decoded) @@ -456,7 +476,7 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ( return nil, errors.New("payload is empty") } var decoded map[string]any - if err := json.Unmarshal(payload, &decoded); err != nil { + if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil { return nil, err } delete(decoded, "input") @@ -591,6 +611,40 @@ func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool return true } +func sanitizeOpenAIWSHistoricalReplayToolCalls( + previousItems []json.RawMessage, + currentItems []json.RawMessage, +) []json.RawMessage { + if len(previousItems) == 0 { + return cloneOpenAIWSRawMessages(previousItems) + } + outputCallIDs := make(map[string]struct{}) + collectOutputCallIDs := func(items []json.RawMessage) { + for _, item := range items { + if !isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) { + continue + } + if callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()); callID != "" { + outputCallIDs[callID] = struct{}{} + } + } + } + collectOutputCallIDs(previousItems) + collectOutputCallIDs(currentItems) + + sanitized := make([]json.RawMessage, 0, len(previousItems)) + for _, item := range previousItems { + if isCodexToolCallContextItemType(gjson.GetBytes(item, "type").String()) { + callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String()) + if _, paired := outputCallIDs[callID]; !paired { + continue + } + } + sanitized = append(sanitized, append(json.RawMessage(nil), item...)) + } + return sanitized +} + func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { if len(payload) == 0 { return false @@ -629,6 +683,7 @@ func buildOpenAIWSReplayInputSequence( if !previousFullInputExists { return cloneOpenAIWSRawMessages(currentItems), currentExists, nil } + previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems) if !currentExists || len(currentItems) == 0 { return cloneOpenAIWSRawMessages(previousFullInput), true, nil } @@ -661,6 +716,33 @@ func setOpenAIWSPayloadInputSequence( return sjson.SetRawBytes(payload, "input", inputRaw) } +func buildOpenAIWSCurrentTurnRetryPayload( + payload []byte, + fullInput []json.RawMessage, + fullInputExists bool, + originalModel string, +) ([]byte, bool, error) { + if !fullInputExists { + return nil, false, nil + } + retryPayload, err := setOpenAIWSPayloadInputSequence(payload, fullInput, true) + if err != nil { + return nil, false, err + } + retryPayload = RemovePreviousResponseIDFromBody(retryPayload) + if model := strings.TrimSpace(originalModel); model != "" { + retryPayload, err = sjson.SetBytes(retryPayload, "model", model) + if err != nil { + return nil, false, err + } + } + coverage := AnalyzeToolCallOutputContextCoverageBytes(retryPayload) + if coverage.HasFunctionCallOutput && !coverage.ContextCoversAllCallIDs { + return nil, false, nil + } + return retryPayload, true, nil +} + func shouldKeepIngressPreviousResponseID( previousPayload []byte, currentPayload []byte, diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index d8270dfe40..6923352d7d 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -400,6 +400,9 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") c.Request.Header.Set("X-Codex-Window-ID", "window-ws") c.Request.Header.Set("X-Codex-Installation-ID", "installation-ws") + c.Request.Header.Set("session-id", "session-ws") + c.Request.Header.Set("thread-id", "thread-ws") + c.Request.Header.Set("x-client-request-id", "client-request-ws") c.Request.Header.Set("X-Test", "blocked") svc := &OpenAIGatewayService{} @@ -421,9 +424,53 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test require.NoError(t, err) require.Equal(t, "window-ws", headers.Get("X-Codex-Window-ID")) require.Equal(t, "installation-ws", headers.Get("X-Codex-Installation-ID")) + require.Equal(t, "session-ws", headers.Get("session-id")) + require.Equal(t, "thread-ws", headers.Get("thread-id")) + require.Equal(t, "client-request-ws", headers.Get("x-client-request-id")) require.Empty(t, headers.Get("X-Test")) } +func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesNamespacedClientSessionIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("X-Codex-Installation-ID", "client-installation") + c.Request.Header.Set("X-Codex-Window-ID", "client-window") + c.Request.Header.Set("session-id", "client-session") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-client-request-id", "client-request") + + account := newTestOAuthAccount(1300, map[string]any{codexFingerprintModeExtraKey: "device"}) + ids := resolveCodexFingerprintIDsFromRequest(account, c.Request.Header) + require.NotNil(t, ids) + stageCodexFingerprintIDs(c, ids) + + svc := &OpenAIGatewayService{} + headers, _, err := svc.buildOpenAIWSHeaders( + context.Background(), + c, + account, + "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, + "", + "", + "", + "", + "", + ) + + require.NoError(t, err) + require.Equal(t, ids.installationID, headers.Get("x-codex-installation-id")) + require.NotEqual(t, "client-installation", headers.Get("x-codex-installation-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "window", "client-window"), headers.Get("x-codex-window-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), headers.Get("session-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), headers.Get("thread-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "request", "client-request"), headers.Get("x-client-request-id")) +} + func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { require.NotPanics(t, func() { logOpenAIWSBindResponseAccountWarn(1, 2, "resp_ok", nil) @@ -751,10 +798,77 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace") require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta")) require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features")) - // OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离, + // OAuth 账号的 session_id/conversation_id 应同时按 API key 和上游账号隔离, // 测试中未设置 api_key 到 context,apiKeyID=0。 - require.Equal(t, isolateOpenAISessionID(0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) - require.Equal(t, isolateOpenAISessionID(0, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) +} + +func TestOpenAIGatewayService_Forward_WSv2_OAuthSanitizesInvalidNativeToolItemID(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + groupID := int64(5662) + c.Set("api_key", &APIKey{GroupID: &groupID}) + + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + captureConn := &openAIWSCaptureConn{events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_oauth_tool_history","model":"gpt-5.6-sol","usage":{"input_tokens":1,"output_tokens":1}}}`), + }} + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + + account := &Account{ + ID: 5662, + Name: "openai-oauth-ws-tool-history", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-oauth-token"}, + Extra: map[string]any{ + "responses_websockets_v2_enabled": true, + }, + } + + body := []byte(`{"model":"gpt-5.6-sol","stream":false,"instructions":"Continue the task.","input":[{"type":"custom_tool_call","id":"fc_hotfix_probe","call_id":"fc_hotfix","name":"exec","input":"pwd","status":"completed"},{"type":"custom_tool_call_output","call_id":"fc_hotfix","output":"done"}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + + captureConn.mu.Lock() + requestPayload := cloneMapStringAny(captureConn.lastWrite) + captureConn.mu.Unlock() + requestJSON := requestToJSONString(requestPayload) + require.Equal(t, "response.create", gjson.Get(requestJSON, "type").String()) + require.False(t, gjson.Get(requestJSON, "input.0.id").Exists(), "stale fc_* ID must not be replayed as a native custom_tool_call ID") + require.Equal(t, "ctc_hotfix", gjson.Get(requestJSON, "input.0.call_id").String()) + require.Equal(t, "custom_tool_call_output", gjson.Get(requestJSON, "input.1.type").String()) + require.Equal(t, "ctc_hotfix", gjson.Get(requestJSON, "input.1.call_id").String()) } func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) { @@ -976,13 +1090,103 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK require.NotNil(t, result) require.Equal(t, "resp_prompt_cache_key", result.RequestID) - // OAuth 账号的 session_id 应被 isolateOpenAISessionID 隔离(apiKeyID=0,未在 context 设置)。 - require.Equal(t, isolateOpenAISessionID(0, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) + // OAuth 账号的 session_id 应同时按 API key 和上游账号隔离(apiKeyID=0)。 + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) require.Empty(t, captureDialer.lastHeaders.Get("conversation_id")) require.NotNil(t, captureConn.lastWrite) require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists()) } +func TestOpenAIGatewayService_Forward_WSv2_CodexFingerprintHandshakeBodyParityAndDefaultCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"header-install","session_id":"header-session","thread_id":"header-thread","turn_id":"header-turn","window_id":"header-window","sandbox":"seatbelt"}`) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + captureConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_ws_fingerprint","model":"gpt-5.2","usage":{"input_tokens":2,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := newTestOAuthAccount(4405, map[string]any{ + codexFingerprintModeExtraKey: "session", + "responses_websockets_v2_enabled": true, + }) + account.Name = "oauth-ws-fingerprint" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + body := []byte(`{"model":"gpt-5.2","stream":true,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session","x-codex-turn-metadata":"{\"installation_id\":\"body-install\",\"session_id\":\"body-session\",\"thread_id\":\"body-thread\",\"turn_id\":\"body-turn\",\"window_id\":\"body-window\",\"sandbox\":\"seatbelt\"}"},"input":[{"type":"input_text","text":"hi"}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "resp_ws_fingerprint", result.RequestID) + require.NotNil(t, captureConn.lastWrite) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantInstall := resolveConvergedInstallationID(account, seed) + wantSession := resolveConvergedSessionID(seed) + wantThread := resolveConvergedThreadID(seed, "header-session") + payloadJSON := requestToJSONString(captureConn.lastWrite) + + require.Equal(t, wantInstall, captureDialer.lastHeaders.Get("x-codex-installation-id")) + require.Equal(t, wantSession, captureDialer.lastHeaders.Get("session-id")) + require.Equal(t, wantSession, captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, wantThread, captureDialer.lastHeaders.Get("thread-id")) + require.Equal(t, wantThread, captureDialer.lastHeaders.Get("x-client-request-id")) + require.Equal(t, wantThread+":0", captureDialer.lastHeaders.Get("x-codex-window-id")) + + require.Equal(t, wantSession, gjson.Get(payloadJSON, "prompt_cache_key").String()) + require.Equal(t, wantInstall, gjson.Get(payloadJSON, "client_metadata.x-codex-installation-id").String()) + require.Equal(t, wantSession, gjson.Get(payloadJSON, "client_metadata.session_id").String()) + require.Equal(t, wantThread, gjson.Get(payloadJSON, "client_metadata.thread_id").String()) + require.Equal(t, wantThread+":0", gjson.Get(payloadJSON, "client_metadata.x-codex-window-id").String()) + + bodyTurnMetadata := gjson.Get(payloadJSON, "client_metadata.x-codex-turn-metadata").String() + headerTurnMetadata := captureDialer.lastHeaders.Get("x-codex-turn-metadata") + require.Equal(t, wantInstall, gjson.Get(bodyTurnMetadata, "installation_id").String()) + require.Equal(t, wantSession, gjson.Get(bodyTurnMetadata, "session_id").String()) + require.Equal(t, wantThread, gjson.Get(bodyTurnMetadata, "thread_id").String()) + require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) + require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) + require.NotZero(t, gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Int()) + require.Equal(t, + gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Int(), + gjson.Get(headerTurnMetadata, "turn_started_at_unix_ms").Int(), + ) +} + func TestOpenAIGatewayService_Forward_WSv2_ResponseDoneUsageParsed(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1765,13 +1969,13 @@ func (c *openAIWSCaptureConn) WriteJSON(ctx context.Context, value any) error { c.writes = append(c.writes, cloneMapStringAny(payload)) case json.RawMessage: var parsed map[string]any - if err := json.Unmarshal(payload, &parsed); err == nil { + if err := decodeOpenAIJSONUseNumber(payload, &parsed); err == nil { c.lastWrite = cloneMapStringAny(parsed) c.writes = append(c.writes, cloneMapStringAny(parsed)) } case []byte: var parsed map[string]any - if err := json.Unmarshal(payload, &parsed); err == nil { + if err := decodeOpenAIJSONUseNumber(payload, &parsed); err == nil { c.lastWrite = cloneMapStringAny(parsed) c.writes = append(c.writes, cloneMapStringAny(parsed)) } diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 6c6d7c9cf3..20ea5eb6b6 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -221,6 +221,49 @@ func normalizeOpenAIWSTerminalEvent(eventType string) string { } } +// markOpenAIWSClientVisibleFailure records only terminal/error protocol events +// that were delivered to the client. Callers invoke it only after any hidden +// failover/recovery decision and a successful downstream write. +func markOpenAIWSClientVisibleFailure(c *gin.Context, eventType string, payload []byte) { + eventType = strings.TrimSpace(eventType) + if eventType != "error" && eventType != "response.failed" { + return + } + prefix := "error" + if eventType == "response.failed" { + prefix = "response.error" + } + code := strings.TrimSpace(gjson.GetBytes(payload, prefix+".code").String()) + errType := strings.TrimSpace(gjson.GetBytes(payload, prefix+".type").String()) + message := strings.TrimSpace(gjson.GetBytes(payload, prefix+".message").String()) + if eventType == "response.failed" && code == "" && errType == "" && message == "" { + prefix = "error" + code = strings.TrimSpace(gjson.GetBytes(payload, prefix+".code").String()) + errType = strings.TrimSpace(gjson.GetBytes(payload, prefix+".type").String()) + message = strings.TrimSpace(gjson.GetBytes(payload, prefix+".message").String()) + } + status := int(gjson.GetBytes(payload, prefix+".status_code").Int()) + if status == 0 { + status = int(gjson.GetBytes(payload, prefix+".status").Int()) + } + if status == 0 && eventType == "error" { + status = int(gjson.GetBytes(payload, "status").Int()) + } + if status == 0 { + status = openAIWSErrorHTTPStatusFromRaw(code, errType) + } + if errType == "" { + errType = "upstream_error" + } + if code == "" { + code = strings.ReplaceAll(eventType, ".", "_") + } + if message == "" { + message = "upstream websocket request failed" + } + MarkOpsStreamFailure(c, errType, code, message, status) +} + func openAIWSPayloadTransientStatus(payload []byte) int { if len(payload) == 0 { return 0 @@ -270,10 +313,7 @@ func (s *OpenAIGatewayService) handleOpenAIWSTerminalTransientFailure(ctx contex if terminalEvent != "response.failed" { return terminalEvent } - status := openAIWSPayloadTransientStatus(payload) - if status != 0 { - s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel) - } + s.handleOpenAIWSFailureAccountSideEffects(ctx, account, canonicalModel, headers, payload) return terminalEvent } @@ -288,6 +328,32 @@ func (s *OpenAIGatewayService) handleOpenAIWSErrorEventTransientFailure(ctx cont } } +// handleOpenAIWSFailureAccountSideEffects applies both structured credential +// failures and transient failures. Its return value lets stream callers avoid +// applying the same transition twice for an error/response.failed pair. +func (s *OpenAIGatewayService) handleOpenAIWSFailureAccountSideEffects(ctx context.Context, account *Account, canonicalModel string, headers http.Header, payload []byte) bool { + message := extractOpenAISSEErrorMessage(payload) + status := openAIStreamFailureStatus(payload, message) + switch status { + case http.StatusUnauthorized, http.StatusTooManyRequests, 529: + s.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, message, headers) + return true + case http.StatusForbidden: + if !openAIStream403AccountFailure(payload, message) { + return false + } + s.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, message, headers) + return true + } + + status = openAIWSPayloadTransientStatus(payload) + if status == 0 { + return false + } + s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel) + return true +} + func (s *OpenAIGatewayService) handleOpenAIWSDialTransientFailure(ctx context.Context, account *Account, canonicalModel string, err error) { var dialErr *openAIWSDialError if !errors.As(err, &dialErr) || dialErr == nil || !shouldCooldownOpenAITransientUpstreamError(dialErr.StatusCode, dialErr.ResponseBody) { @@ -486,9 +552,11 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return 0, nil, "", nil } - // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, - // 以保持“回滚到 HTTP”后的历史行为一致性。 - if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + // OAuth/SetupToken continuation state lives on the WSv2 session and cannot + // survive an HTTP fallback. Official API-key Responses HTTP requests are + // different: previous_response_id is supported by the provider and scoped to + // the selected key/project, so the response-id binding must retain that key. + if !account.IsOpenAIApiKey() && s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { return 0, nil, "", nil } if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { @@ -528,6 +596,12 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return 0, nil, "", nil } + if !s.openAIAccountMatchesSchedulingGroup(latest, groupID) { + return 0, nil, "", nil + } + if s.openAIGroupRequiresPrivacySet(ctx, groupID) && !latest.IsPrivacySet() { + return 0, nil, "", nil + } if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return 0, nil, "", nil @@ -619,6 +693,18 @@ func (s *OpenAIGatewayService) persistOpenAIWSRateLimitSignal(ctx context.Contex s.handleOpenAIAccountUpstreamError(ctx, account, http.StatusTooManyRequests, headers, responseBody) } +func (s *OpenAIGatewayService) newOpenAIWSRateLimitFailoverError(account *Account, headers http.Header, responseBody []byte, message string) *UpstreamFailoverError { + return s.newOpenAIAccountFailoverError( + account, + http.StatusTooManyRequests, + headers, + responseBody, + strings.TrimSpace(message), + false, + false, + ) +} + func classifyOpenAIWSErrorEventFromRaw(codeRaw, errTypeRaw, msgRaw string) (string, bool) { code := strings.ToLower(strings.TrimSpace(codeRaw)) errType := strings.ToLower(strings.TrimSpace(errTypeRaw)) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index e0dbd58bd4..7702ebe797 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -22,6 +22,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( c *gin.Context, account *Account, reqBody map[string]any, + clientPromptCacheKey string, token string, decision OpenAIWSProtocolDecision, isCodexCLI bool, @@ -62,9 +63,22 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( payload := s.buildOpenAIWSCreatePayload(reqBody, account) payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt) + turnState := "" + turnMetadata := "" + if c != nil && c.Request != nil { + turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) + turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) + } + setOpenAIWSTurnMetadata(payload, turnMetadata) + applyStagedCodexFingerprintClientMetadata(c, account, payload) previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) - promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") + promptCacheKey := strings.TrimSpace(clientPromptCacheKey) + if promptCacheKey == "" { + // Fingerprint convergence may inject a default key when the client did + // not send one; retain that fallback without replacing an explicit raw key. + promptCacheKey = openAIWSPayloadString(payload, "prompt_cache_key") + } _, hasTools := payload["tools"] debugEnabled := isOpenAIWSModeDebugEnabled() payloadBytes := -1 @@ -79,13 +93,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if raw, ok := payload["stream"]; ok { streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw))) } - turnState := "" - turnMetadata := "" - if c != nil && c.Request != nil { - turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) - turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) - } - setOpenAIWSTurnMetadata(payload, turnMetadata) payloadEventType := openAIWSPayloadString(payload, "type") if payloadEventType == "" { payloadEventType = "response.create" @@ -571,8 +578,9 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( message = corrected } } + message = restoreCodexToolNamesFromContext(c, message) } - if openAIWSEventShouldParseUsage(eventType) { + if openAIWSMessageShouldParseUsage(eventType, message) { parseOpenAIWSResponseUsageFromCompletedEvent(message, usage) } imageCounter.AddSSEData(message) @@ -690,6 +698,9 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } if isTerminalEvent { + if !clientDisconnected { + markOpenAIWSClientVisibleFailure(c, eventType, message) + } upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message) // A terminal event must be the final JSON document in its WS message. // Ignore any tail for the completed client turn, but never reuse the @@ -768,9 +779,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( UpstreamModel: mappedModel, UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), ImageCount: imageCounter.Count(), ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), Stream: reqStream, OpenAIWSMode: true, diff --git a/backend/internal/service/openai_ws_forwarder_v2_test.go b/backend/internal/service/openai_ws_forwarder_v2_test.go new file mode 100644 index 0000000000..7cfd7f9928 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_v2_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// HTTP POST /v1/responses → forwardOpenAIWSV2 共用 stream/non-stream 的 +// OpenAIForwardResult:上游 response.completed.service_tier 必须覆盖请求 +// fast/priority,不能只读 reqBody。 +func TestForwardOpenAIWSV2_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + requestTier string + stream bool + }{ + {name: "priority_nonstream", requestTier: "priority", stream: false}, + {name: "fast_stream", requestTier: "fast", stream: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + captureConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_tier_v2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5882, + Name: "openai-ws-v2-tier", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + body := []byte(fmt.Sprintf( + `{"model":"gpt-5.5","stream":%t,"service_tier":%q,"input":[{"type":"input_text","text":"hi"}]}`, + tc.stream, tc.requestTier, + )) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.OpenAIWSMode, "must take HTTP POST → forwardOpenAIWSV2, not HTTP fallback") + require.Equal(t, tc.stream, result.Stream) + require.Equal(t, "resp_tier_v2", result.RequestID) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) + require.Equal(t, "priority", captureConn.lastWrite["service_tier"], + "outbound WS payload still carries the requested Fast tier") + }) + } +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 87abf1ef04..a6c0528045 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -22,6 +23,57 @@ const ( openAIWSHTTPBridgeErrorBodyLimitBytes = 64 * 1024 ) +const openAIWSHTTPBridgeToolStateContextKey = "openai_ws_http_bridge_tool_state" + +type openAIWSHTTPBridgeToolState struct { + ClientMapping apicompat.ResponsesClientToolMapping + LoweredTools json.RawMessage +} + +func openAIWSHTTPBridgeToolStateFromContext(c *gin.Context) (openAIWSHTTPBridgeToolState, bool) { + if c == nil { + return openAIWSHTTPBridgeToolState{}, false + } + value, ok := c.Get(openAIWSHTTPBridgeToolStateContextKey) + state, typed := value.(openAIWSHTTPBridgeToolState) + return state, ok && typed +} + +func setOpenAIWSHTTPBridgeToolState(c *gin.Context, state openAIWSHTTPBridgeToolState) { + if c == nil { + return + } + state.LoweredTools = append(json.RawMessage(nil), state.LoweredTools...) + c.Set(openAIWSHTTPBridgeToolStateContextKey, state) +} + +func decodeOpenAIWSHTTPBridgeLoweredTools(raw json.RawMessage) []any { + if len(raw) == 0 { + return nil + } + var tools []any + if err := json.Unmarshal(raw, &tools); err != nil { + return nil + } + return tools +} + +func openAIWSHTTPBridgeRawField(body []byte, name string) (json.RawMessage, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return nil, false + } + raw, present := fields[name] + return append(json.RawMessage(nil), raw...), present +} + +func openAIWSHTTPBridgeToolUpstreamName(account *Account) string { + if account != nil && account.Platform == PlatformGrok { + return "Grok WS HTTP bridge" + } + return "OpenAI WS HTTP bridge" +} + // ResolveOpenAIWSClientFirstMessageTimeout returns the effective client ingress deadline. func ResolveOpenAIWSClientFirstMessageTimeout(cfg *config.Config) time.Duration { seconds := config.DefaultOpenAIWSClientFirstMessageTimeoutSeconds @@ -63,9 +115,9 @@ func (s *OpenAIGatewayService) shouldBridgeOpenAIWSHTTP(account *Account, payloa return threshold > 0 && int64(payloadBytes) >= threshold } -func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { +func prepareOpenAIWSHTTPBridgeBody(account *Account, payload []byte) ([]byte, error) { var body map[string]any - if err := json.Unmarshal(payload, &body); err != nil { + if err := decodeOpenAIJSONUseNumber(payload, &body); err != nil { return nil, err } if body == nil { @@ -74,25 +126,31 @@ func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { delete(body, "type") delete(body, "generate") delete(body, "previous_response_id") + deleteOpenAIResponsesNoneReasoningEffortFromObject(account, body) body["stream"] = true return json.Marshal(body) } type openAIWSToolCallReplayCollector struct { - items []json.RawMessage - seen map[string]struct{} + items []json.RawMessage + seen map[string]struct{} + allItems []json.RawMessage + allSeen map[string]struct{} } func (c *openAIWSToolCallReplayCollector) AddEvent(eventType string, message []byte) { switch strings.TrimSpace(eventType) { case "response.output_item.done": - c.addItem(gjson.GetBytes(message, "item")) + item := gjson.GetBytes(message, "item") + c.addAllItem(item) + c.addItem(item) case "response.completed", "response.done": output := gjson.GetBytes(message, "response.output") if !output.IsArray() { return } for _, item := range output.Array() { + c.addAllItem(item) c.addItem(item) } } @@ -102,6 +160,35 @@ func (c *openAIWSToolCallReplayCollector) Items() []json.RawMessage { return cloneOpenAIWSRawMessages(c.items) } +func (c *openAIWSToolCallReplayCollector) AllItems() []json.RawMessage { + return cloneOpenAIWSRawMessages(c.allItems) +} + +func (c *openAIWSToolCallReplayCollector) addAllItem(item gjson.Result) { + if !item.Exists() || item.Type != gjson.JSON { + return + } + raw := strings.TrimSpace(item.Raw) + if raw == "" || !strings.HasPrefix(raw, "{") || strings.TrimSpace(item.Get("type").String()) == "" { + return + } + key := strings.TrimSpace(item.Get("id").String()) + if key == "" { + key = strings.TrimSpace(item.Get("call_id").String()) + } + if key == "" { + key = raw + } + if c.allSeen == nil { + c.allSeen = make(map[string]struct{}) + } + if _, ok := c.allSeen[key]; ok { + return + } + c.allSeen[key] = struct{}{} + c.allItems = append(c.allItems, json.RawMessage(raw)) +} + func (c *openAIWSToolCallReplayCollector) addItem(item gjson.Result) { if !item.Exists() || item.Type != gjson.JSON { return @@ -153,6 +240,43 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte { return body } +func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte, fallbackMessage string) []byte { + errorType := strings.TrimSpace(gjson.GetBytes(source, "error.type").String()) + if errorType == "" { + errorType = strings.TrimSpace(gjson.GetBytes(source, "response.error.type").String()) + } + code := strings.TrimSpace(gjson.GetBytes(source, "error.code").String()) + if code == "" { + code = strings.TrimSpace(gjson.GetBytes(source, "response.error.code").String()) + } + if code == "" { + code = "upstream_error" + } + message := extractOpenAISSEErrorMessage(source) + if message == "" { + message = strings.TrimSpace(fallbackMessage) + } + if message == "" { + message = "Upstream response failed" + } + errorBody := map[string]any{"code": code, "message": message} + if errorType != "" { + errorBody["type"] = errorType + } + response := map[string]any{ + "id": responseID, "object": "response", "status": "failed", + "output": []any{}, "error": errorBody, + } + if model = strings.TrimSpace(model); model != "" { + response["model"] = model + } + body, err := json.Marshal(map[string]any{"type": "response.failed", "response": response}) + if err != nil { + return []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`) + } + return body +} + func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( ctx context.Context, c *gin.Context, @@ -182,43 +306,99 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } responseModelObserver := &upstreamResponseModelObserver{} - body, err := prepareOpenAIWSHTTPBridgeBody(payload) + body, err := prepareOpenAIWSHTTPBridgeBody(account, payload) if err != nil { return nil, fmt.Errorf("prepare http bridge body: %w", err) } + grokIntentSourceBody := append([]byte(nil), body...) + _, grokExplicitToolsField := openAIWSHTTPBridgeRawField(grokIntentSourceBody, "tools") + grokExplicitToolIntent := account.Platform == PlatformGrok && hasGrokResponsesToolIntent(grokIntentSourceBody) + var clientToolMapping apicompat.ResponsesClientToolMapping + functionToolUpstream := (account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey) || account.Platform == PlatformGrok + if functionToolUpstream { + if account.Platform == PlatformGrok { + body, err = sanitizeGrokResponsesInput(body) + if err != nil { + return nil, fmt.Errorf("sanitize Grok WS HTTP bridge input: %w", err) + } + } + inheritedState, _ := openAIWSHTTPBridgeToolStateFromContext(c) + inheritedLoweredTools := decodeOpenAIWSHTTPBridgeLoweredTools(inheritedState.LoweredTools) + body, clientToolMapping, err = adaptResponsesClientToolsForFunctionUpstreamWithMapping( + body, + openAIWSHTTPBridgeToolUpstreamName(account), + inheritedState.ClientMapping, + inheritedLoweredTools, + ) + if err != nil { + return nil, fmt.Errorf("adapt %s client tools: %w", openAIWSHTTPBridgeToolUpstreamName(account), err) + } + if account.Platform == PlatformGrok && !grokExplicitToolsField && !grokExplicitToolIntent && len(inheritedLoweredTools) > 0 && hasGrokResponsesToolIntent(body) { + // This continuation omitted tools, so the pre-adapter source cannot + // represent the effective inherited declarations. Cache routing must + // see the rehydrated tool intent or it will replace client functions + // with the native-search tool-free route. Explicit current-turn tool + // intent still uses the original pre-sanitization source above. + grokIntentSourceBody = append(grokIntentSourceBody[:0], body...) + } + loweredTools := inheritedState.LoweredTools + if currentTools, present := openAIWSHTTPBridgeRawField(body, "tools"); present { + loweredTools = currentTools + } + setOpenAIWSHTTPBridgeToolState(c, openAIWSHTTPBridgeToolState{ + ClientMapping: clientToolMapping, + LoweredTools: loweredTools, + }) + } + if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { + liteBody, liteChanged, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) + if liteErr != nil { + return nil, fmt.Errorf("normalize responses Lite payload: %w", liteErr) + } + if liteChanged { + body = liteBody + } + } - upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - var upstreamReq *http.Request + buildUpstreamRequest := func(requestBody []byte) (*http.Request, error) { + upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) + defer releaseUpstreamCtx() + var upstreamReq *http.Request + var buildErr error + if account.Platform == PlatformGrok { + upstreamReq, buildErr = buildGrokResponsesRequest(upstreamCtx, c, account, requestBody, token, grokCacheIdentity, s.cfg, s.settingService) + } else { + upstreamReq, buildErr = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, requestBody, token) + } + if buildErr != nil { + return nil, buildErr + } + if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { + upstreamReq.Header.Set(responsesLiteHeader, "true") + } + return upstreamReq, nil + } if account.Platform == PlatformGrok { upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) - grokIntentSourceBody := body body, err = patchGrokResponsesBody(body, upstreamModel) if err != nil { - releaseUpstreamCtx() return nil, err } grokMixedCacheIntentBody := append([]byte(nil), body...) body, err = applyGrokResponsesCacheIdentity(body, grokIntentSourceBody, grokCacheIdentity, account.IsGrokOAuth()) if err != nil { - releaseUpstreamCtx() return nil, fmt.Errorf("apply grok prompt cache identity: %w", err) } body, err = applyGrokFreeRequestToolCacheRoute(c, body, grokMixedCacheIntentBody, account, grokCacheIdentity) if err != nil { - releaseUpstreamCtx() return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err) } - upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg, s.settingService) - } else { - upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token) } - releaseUpstreamCtx() - if err != nil { - return nil, err - } - if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { - upstreamReq.Header.Set(responsesLiteHeader, "true") + actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if actualModel == "" { + actualModel = canonicalOpenAIAccountSchedulingModel(account, originalModel) } + SetOpsUpstreamModel(c, actualModel) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { @@ -230,19 +410,47 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } turnStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) - if err != nil { - if turn == 1 { - return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body) + var resp *http.Response + for { + upstreamReq, buildErr := buildUpstreamRequest(body) + if buildErr != nil { + return nil, buildErr + } + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) + if err != nil { + if turn == 1 { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + clientError := buildOpenAIWSHTTPBridgeErrorEvent(http.StatusBadGateway, "Upstream request failed") + if writeErr := writeClientMessage(clientError); writeErr == nil { + markOpenAIWSClientVisibleFailure(c, "error", clientError) + } + return nil, fmt.Errorf("upstream http bridge request failed: %s", safeErr) + } + if resp.StatusCode < 400 { + break } - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - _ = writeClientMessage(buildOpenAIWSHTTPBridgeErrorEvent(http.StatusBadGateway, "Upstream request failed")) - return nil, fmt.Errorf("upstream http bridge request failed: %s", safeErr) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes)) + _ = resp.Body.Close() + retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody) + if retryErr != nil { + return nil, fmt.Errorf("normalize websocket http bridge rejected field retry: %w", retryErr) + } + if changed && rejectedFieldRetryState.Allow(retryBody) { + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_rejected_field_retry account_id=%d turn=%d reason=%s", + account.ID, + turn, + truncateOpenAIWSLogValue(retryReason, openAIWSLogValueMaxLen), + ) + body = retryBody + payloadBytes = len(body) + continue + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) if upstreamMsg == "" { upstreamMsg = http.StatusText(resp.StatusCode) @@ -251,19 +459,24 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if account.Platform == PlatformGrok { shouldFailover = s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, resolveGrokWSUpstreamModel(account, body, originalModel)), account, resp.StatusCode, resp.Header, respBody) - if turn == 1 && shouldFailover { + if shouldFailover && (turn == 1 || resp.StatusCode == http.StatusTooManyRequests) { return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, false) } - } else if turn == 1 && shouldFailover { + } else if shouldFailover && (turn == 1 || resp.StatusCode == http.StatusTooManyRequests) { return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, respBody) } if account.Platform != PlatformGrok && (shouldFailover || shouldCooldownOpenAITransientUpstreamError(resp.StatusCode, respBody)) { - canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) - s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, canonicalModel) + s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, actualModel) + } + clientError := buildOpenAIWSHTTPBridgeErrorEvent(resp.StatusCode, upstreamMsg) + if writeErr := writeClientMessage(clientError); writeErr == nil { + markOpenAIWSClientVisibleFailure(c, "error", clientError) } - _ = writeClientMessage(buildOpenAIWSHTTPBridgeErrorEvent(resp.StatusCode, upstreamMsg)) return nil, fmt.Errorf("upstream http bridge error: status=%d message=%s", resp.StatusCode, upstreamMsg) } + defer func() { _ = resp.Body.Close() }() + stopCancelBody := context.AfterFunc(ctx, func() { _ = resp.Body.Close() }) + defer stopCancelBody() if account.Platform == PlatformGrok { s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, resolveGrokWSUpstreamModel(account, body, originalModel)), account, resp.Header, resp.StatusCode) } @@ -282,15 +495,19 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( upstreamTerminalEvent := "" sawDone := false wroteDownstream := false + pendingClientMessages := make([][]byte, 0, 4) + pendingClientMessageBytes := int64(0) + capacityFailoverSuppressedLogged := false clientDisconnected := false - mappedModel := "" + officialOpenAIResponses := account != nil && account.Platform == PlatformOpenAI + bareErrorPending := false + var bareErrorPayload []byte + bareErrorMessage := "" + failureAccountSideEffectsApplied := false + mappedModel := actualModel needModelReplace := false var mappedModelBytes []byte if originalModel != "" { - mappedModel = strings.TrimSpace(gjson.GetBytes(body, "model").String()) - if mappedModel == "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) - } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) @@ -306,7 +523,8 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( UpstreamModel: mappedModel, UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), - ServiceTier: extractOpenAIServiceTierFromBody(body), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(body)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), Stream: reqStream, OpenAIWSMode: true, @@ -319,6 +537,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( result.wsReplayInput = replayInput result.wsReplayInputExists = true } + result.wsAccountFailoverReplayInput = replayCollector.AllItems() if imageCount > 0 { result.ImageCount = imageCount result.ImageSize = imageSizeTier @@ -329,17 +548,60 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return result } - scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { maxLineSize = s.cfg.Gateway.MaxLineSize } + if hasResponsesClientToolMapping(clientToolMapping) { + resp.Body = newResponsesClientToolStreamBody(resp.Body, clientToolMapping, maxLineSize) + } + scanner := bufio.NewScanner(resp.Body) scanBuf := getSSEScannerBuf64K() scanner.Buffer(scanBuf[:0], maxLineSize) defer putSSEScannerBuf64K(scanBuf) + pendingSSEEventType := "" + finalizeBareError := func() error { + if !bareErrorPending { + return nil + } + if !failureAccountSideEffectsApplied { + failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, mappedModel, resp.Header, bareErrorPayload) + } + upstreamTerminalEvent = "response.failed" + if clientDisconnected { + return nil + } + clientMessage := buildOpenAIWSHTTPBridgeFailedEvent(responseID, originalModel, bareErrorPayload, bareErrorMessage) + if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(clientMessage); changed { + clientMessage = rewritten + } + messages := append(pendingClientMessages, clientMessage) + pendingClientMessages = nil + pendingClientMessageBytes = 0 + for _, message := range messages { + if err := writeClientMessage(message); err != nil { + if isOpenAIWSClientDisconnectError(err) { + clientDisconnected = true + return nil + } + return fmt.Errorf("write synthesized websocket response.failed: %w", err) + } + wroteDownstream = true + } + markOpenAIWSClientVisibleFailure(c, "response.failed", clientMessage) + return nil + } for scanner.Scan() { line := scanner.Text() + if eventType, ok := extractOpenAISSEEventLine(line); ok { + pendingSSEEventType = eventType + continue + } + if strings.TrimSpace(line) == "" { + pendingSSEEventType = "" + continue + } data, ok := extractOpenAISSEDataLine(line) if !ok { continue @@ -353,7 +615,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( continue } - upstreamMessage := []byte(trimmedData) + upstreamMessage := []byte(openAICompatPayloadWithEventType(trimmedData, pendingSSEEventType)) if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed { upstreamMessage = normalized } @@ -376,7 +638,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( firstTokenMs = &ms } } - if openAIWSEventShouldParseUsage(eventType) { + if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) } imageCounter.AddSSEData(upstreamMessage) @@ -392,15 +654,31 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( replayCollector.AddEvent(eventType, upstreamMessage) var upstreamEventErr error - if eventType == "error" { - errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) - errMessage := strings.TrimSpace(errMsgRaw) + if officialOpenAIResponses && bareErrorPending && (eventType == "response.completed" || eventType == "response.done") { + // Some upstreams emit a recoverable bare error before the authoritative + // successful terminal. Do not replace that terminal with a synthetic + // failure or retain side effects from the superseded error. + bareErrorPending = false + bareErrorPayload = nil + bareErrorMessage = "" + } + suppressClientMessage := officialOpenAIResponses && bareErrorPending && eventType != "response.failed" + if eventType == "error" || eventType == "response.failed" { + errMessage := extractOpenAISSEErrorMessage(upstreamMessage) if errMessage == "" { errMessage = "upstream error event" } - statusCode := openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) - shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(statusCode, errMessage, upstreamMessage) - if account.Platform == PlatformGrok { + statusCode := openAIStreamFailureStatus(upstreamMessage, errMessage) + shouldFailover := openAIStreamFailedEventShouldFailover(upstreamMessage, errMessage) + if eventType == "error" { + errCodeRaw, errTypeRaw, _ := parseOpenAIWSErrorEventFields(upstreamMessage) + shouldFailover = openAIStreamErrorEventShouldFailover(upstreamMessage, errMessage) + if account.Platform == PlatformGrok { + statusCode = openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) + } + } + requestScopedCapacity := isOpenAIUpstreamCapacityShedEvent(upstreamMessage) + if account.Platform == PlatformGrok && eventType == "error" { // SSE error events do not carry an HTTP status. The local status // mapper therefore defaults unknown xAI codes (for example // new_sensitive) to 502; classify the body as a request-scoped @@ -411,18 +689,32 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( shouldFailover = s.shouldFailoverGrokUpstreamError(statusCode, upstreamMessage) s.handleGrokAccountUpstreamError(ctx, account, statusCode, resp.Header, upstreamMessage) } - } else if shouldFailover { - accountStatus := statusCode - if transientStatus := openAIWSPayloadTransientStatus(upstreamMessage); transientStatus != 0 { - accountStatus = transientStatus + } + if !wroteDownstream && shouldFailover && (turn == 1 || statusCode == http.StatusTooManyRequests) { + if account.Platform == PlatformGrok { + return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false) } - canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) - s.handleOpenAIAccountUpstreamError(ctx, account, accountStatus, resp.Header, upstreamMessage, canonicalModel) + return nil, s.newOpenAIStreamFailoverError(c, account, true, resp.Header.Get("x-request-id"), upstreamMessage, errMessage, resp.Header) } - if turn == 1 && !wroteDownstream && shouldFailover { - return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false) + if account.Platform != PlatformGrok && !failureAccountSideEffectsApplied { + if eventType == "response.failed" || (!officialOpenAIResponses && shouldFailover && !requestScopedCapacity) { + failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, mappedModel, resp.Header, upstreamMessage) + } + } + if wroteDownstream && requestScopedCapacity && !capacityFailoverSuppressedLogged { + logOpenAICapacityFailoverSuppressed(ctx, account, "ws_http_bridge", resp.Header.Get("x-request-id"), eventType) + capacityFailoverSuppressedLogged = true + } + if eventType == "error" && !officialOpenAIResponses { + upstreamEventErr = errors.New(errMessage) + } else if eventType == "error" { + bareErrorPending = true + bareErrorPayload = append(bareErrorPayload[:0], upstreamMessage...) + bareErrorMessage = errMessage + suppressClientMessage = true + } else { + bareErrorPending = false } - upstreamEventErr = errors.New(errMessage) } // 客户端写出副本改写容量降载码:Codex 对 error/response.failed 中的 @@ -435,35 +727,66 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( clientMessage = rewritten } } - if !clientDisconnected { - if err := writeClientMessage(clientMessage); err != nil { - if isOpenAIWSClientDisconnectError(err) { - clientDisconnected = true - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) - logOpenAIWSModeInfo( - "ingress_ws_http_bridge_client_disconnected_drain account_id=%d turn=%d close_status=%s close_reason=%s", - account.ID, - turn, - closeStatus, - truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), - ) - } else { - return nil, wrapOpenAIWSIngressTurnError( - "write_client", - fmt.Errorf("write client websocket event: %w", err), - wroteDownstream, + if !clientDisconnected && !suppressClientMessage { + stageBeforeSemanticOutput := turn == 1 && account.Platform == PlatformOpenAI && !wroteDownstream + commitStagedMessages := !stageBeforeSemanticOutput || + openAIStreamDataStartsClientOutput(string(clientMessage), eventType) || + isOpenAIWSTerminalEvent(eventType) + if stageBeforeSemanticOutput && !commitStagedMessages { + if pendingClientMessageBytes+int64(len(clientMessage)) > openAIFirstOutputStageMaxBytes { + return nil, s.newOpenAIStreamFailoverError( + c, + account, + true, + resp.Header.Get("x-request-id"), + nil, + "OpenAI WS HTTP bridge first-output staging limit exceeded", + resp.Header, ) } + pendingClientMessages = append(pendingClientMessages, append([]byte(nil), clientMessage...)) + pendingClientMessageBytes += int64(len(clientMessage)) } else { - wroteDownstream = true + messages := append(pendingClientMessages, clientMessage) + pendingClientMessages = nil + pendingClientMessageBytes = 0 + for _, message := range messages { + if err := writeClientMessage(message); err != nil { + if isOpenAIWSClientDisconnectError(err) { + clientDisconnected = true + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_client_disconnected_drain account_id=%d turn=%d close_status=%s close_reason=%s", + account.ID, + turn, + closeStatus, + truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), + ) + break + } + return nil, wrapOpenAIWSIngressTurnError( + "write_client", + fmt.Errorf("write client websocket event: %w", err), + wroteDownstream, + ) + } + wroteDownstream = true + } } } + if !clientDisconnected && !suppressClientMessage { + markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage) + } if upstreamEventErr != nil { return resultWithUsage(), upstreamEventErr } - if isOpenAIWSTerminalEvent(eventType) { - upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, upstreamMessage) + if isOpenAIWSTerminalEvent(eventType) && !bareErrorPending { + if eventType == "response.failed" { + upstreamTerminalEvent = "response.failed" + } else { + upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, resp.Header, upstreamMessage) + } terminalEventCount++ firstTokenMsValue := -1 if firstTokenMs != nil { @@ -487,6 +810,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return resultWithUsage(), nil } } + if bareErrorPending { + if finalizeErr := finalizeBareError(); finalizeErr != nil { + return resultWithUsage(), finalizeErr + } + if scanErr := scanner.Err(); scanErr != nil { + return resultWithUsage(), fmt.Errorf("read upstream http bridge stream after error event: %w", scanErr) + } + return resultWithUsage(), errors.New(bareErrorMessage) + } if err := scanner.Err(); err != nil { streamErr := fmt.Errorf("read upstream http bridge stream: %w", err) if turn == 1 && !wroteDownstream { @@ -505,7 +837,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) { - body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload) + body, err := prepareOpenAIWSHTTPBridgeBody(account, seedPayload) if err != nil { return "", err } diff --git a/backend/internal/service/openai_ws_http_bridge_resume_test.go b/backend/internal/service/openai_ws_http_bridge_resume_test.go new file mode 100644 index 0000000000..1468892bbe --- /dev/null +++ b/backend/internal/service/openai_ws_http_bridge_resume_test.go @@ -0,0 +1,263 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBuildOpenAIWSCurrentTurnRetryPayloadRejectsOrphanToolOutput(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"mapped-model","previous_response_id":"resp_old"}`) + fullInput := []json.RawMessage{ + json.RawMessage(`{"type":"function_call_output","call_id":"missing_call","output":"done"}`), + } + + retryPayload, retrySafe, err := buildOpenAIWSCurrentTurnRetryPayload(payload, fullInput, true, "gpt-5.6-sol") + + require.NoError(t, err) + require.False(t, retrySafe) + require.Nil(t, retryPayload) +} + +func TestProxyOpenAIWSHTTPBridgeTurnLaterTurn429FailsOverBeforeClientWrite(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"60"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 129, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_old","input":[{"role":"user","content":"continue"}]}`) + writes := 0 + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", payload, len(payload), + "gpt-5.6-sol", "", "", "", "", 281, + func([]byte) error { + writes++ + return nil + }, + ) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.Zero(t, writes) +} + +func TestProxyOpenAIWSHTTPBridgeTurnLaterTurnDoesNotFailOverAfterDownstreamOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"limited\"}}\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 281, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.NotNil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Len(t, writes, 2) + require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[0], "type").String()) + // P0.1: after downstream output, retain the upstream error and synthesize + // the official Responses terminal so Codex clients do not see a bare error + // followed by a closed stream. + require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) +} + +func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + openAIWSTurnStateHeader: []string{"old-account-state"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_first\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"first-ok\"}]},{\"id\":\"fc_1\",\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"inspect\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", + )), + }, + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"60"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_second\",\"output\":[{\"id\":\"msg_2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"second-ok\"}]}],\"usage\":{\"input_tokens\":4,\"output_tokens\":1}}}\n\n", + )), + }, + }} + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: upstream, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 129, Name: "limited", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, Concurrency: 1, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Credentials: map[string]any{"chatgpt_account_id": "account-a", "chatgpt_user_id": "user-a"}, + } + nextAccount := *account + nextAccount.ID = 130 + nextAccount.Name = "replacement" + nextAccount.Credentials = map[string]any{"chatgpt_account_id": "account-b", "chatgpt_user_id": "user-b"} + + serverErrCh := make(chan error, 1) + failoverCh := make(chan []byte, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + serverErrCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = r.Clone(r.Context()) + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil { + serverErrCh <- readErr + return + } + proxyErr := svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token-a", firstMessage, nil) + var failoverErr *UpstreamFailoverError + if !errors.As(proxyErr, &failoverErr) { + serverErrCh <- proxyErr + return + } + retryPayload, retryCurrentTurn := OpenAIWSCurrentTurnRetryPayload(proxyErr) + if !retryCurrentTurn || len(retryPayload) == 0 { + serverErrCh <- errors.New("missing current-turn retry payload") + return + } + failoverCh <- retryPayload + serverErrCh <- svc.ProxyResponsesWebSocketFromClient( + r.Context(), ginCtx, conn, &nextAccount, "access-token-b", retryPayload, nil, + ) + })) + defer wsServer.Close() + + dialCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancel() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[{"role":"user","content":"first"}]}`)) + cancel() + require.NoError(t, err) + + readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _, completed, err := clientConn.Read(readCtx) + cancel() + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) + + writeCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","prompt_cache_key":"client-session","client_metadata":{"session_id":"client-session","thread_id":"client-thread"},"input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`)) + cancel() + require.NoError(t, err) + + readCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + _, retriedCompleted, err := clientConn.Read(readCtx) + cancel() + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(retriedCompleted, "type").String()) + require.Equal(t, "resp_second", gjson.GetBytes(retriedCompleted, "response.id").String()) + _ = clientConn.Close(websocket.StatusNormalClosure, "done") + + select { + case retryPayload := <-failoverCh: + require.NotEmpty(t, retryPayload) + require.False(t, gjson.GetBytes(retryPayload, "previous_response_id").Exists()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(retryPayload, "model").String()) + input := gjson.GetBytes(retryPayload, "input") + require.True(t, input.IsArray()) + require.Len(t, input.Array(), 4) + require.Contains(t, input.Raw, "first") + require.Contains(t, input.Raw, "first-ok") + require.Contains(t, input.Raw, "second") + require.Equal(t, 1, strings.Count(input.Raw, `"id":"fc_1"`)) + require.Equal(t, 2, strings.Count(input.Raw, `"call_id":"call_1"`)) + require.Equal(t, "client-session", gjson.GetBytes(retryPayload, "client_metadata.session_id").String()) + require.Equal(t, "client-thread", gjson.GetBytes(retryPayload, "client_metadata.thread_id").String()) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for current-turn failover") + } + + select { + case proxyErr := <-serverErrCh: + require.NoError(t, proxyErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for replacement-account completion") + } + require.Len(t, upstream.bodies, 3) + require.Contains(t, string(upstream.bodies[0]), "first") + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String()) + require.NotContains(t, string(upstream.bodies[2]), "previous_response_id") + require.Contains(t, string(upstream.bodies[2]), "second") + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String()) + require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader)) +} diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 1e352f012e..bbff773d76 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -31,7 +31,7 @@ func TestResolveOpenAIWSClientFirstMessageTimeout(t *testing.T) { } func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { - body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi"}`)) + body, err := prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`)) require.NoError(t, err) require.False(t, gjson.GetBytes(body, "type").Exists()) require.False(t, gjson.GetBytes(body, "generate").Exists()) @@ -39,6 +39,664 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { require.Equal(t, "gpt-5", gjson.GetBytes(body, "model").String()) require.True(t, gjson.GetBytes(body, "stream").Bool()) require.Equal(t, "hi", gjson.GetBytes(body, "input").String()) + require.Equal(t, "900719925474099312345", gjson.GetBytes(body, "sequence").Raw) + _, err = prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create"}{"trailing":true}`)) + require.Error(t, err) +} + +func TestPrepareOpenAIWSHTTPBridgeBodyStripsNoneReasoningForCompatibleEndpoint(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"company-coding-model","reasoning":{"effort":"none"},"input":"hi"}`) + compatible := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }} + + body, err := prepareOpenAIWSHTTPBridgeBody(compatible, payload) + require.NoError(t, err) + require.False(t, gjson.GetBytes(body, "reasoning.effort").Exists()) + require.False(t, gjson.GetBytes(body, "reasoning").Exists()) + + officialBody, err := prepareOpenAIWSHTTPBridgeBody(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, payload) + require.NoError(t, err) + require.Equal(t, "none", gjson.GetBytes(officialBody, "reasoning.effort").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + // proxyOpenAIWSHTTPBridgeTurn 是 client WS→HTTP bridge,本身不 canonicalize + // fast→priority;生产入口的归一化在 openai_ws_forwarder_ingress.go 的 fast + // policy。本测试只覆盖局部 observer:canonical 请求 priority 被上游 + // response.completed service_tier=default 覆盖。 + sse := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_tier","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5881, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{"type":"response.create","model":"gpt-5.5","stream":true,"service_tier":"priority","input":"hi"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5.5", "", "", "", "", 1, + func([]byte) error { return nil }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) +} + +func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultWinsOverFastAlias(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 客户端别名 fast 同样被上游回显的 default 覆盖:局部 observer 的 + // ServiceTier() 是唯一计费依据,绝不回退到请求侧 fast。 + sse := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_tier2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5882, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{"type":"response.create","model":"gpt-5.5","stream":true,"service_tier":"fast","input":"hi"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5.5", "", "", "", "", 1, + func([]byte) error { return nil }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "local observer's upstream-echoed default must win over the fast alias") +} + +func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","status":"in_progress"}}`, + ``, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}`, + ``, + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}}`, + ``, + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_tools","status":"completed","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5659, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{ + "type":"response.create","model":"gpt-5","stream":true, + "tools":[{"type":"custom","name":"exec","description":"Run a command"}], + "input":[ + {"type":"custom_tool_call","id":"previous_item","call_id":"previous_call","name":"exec","input":"echo ready"}, + {"type":"custom_tool_call_output","call_id":"previous_call","output":"ready"} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5", "", "", "", "", 2, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "function", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) + require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.0.type").String()) + require.JSONEq(t, `{"input":"echo ready"}`, gjson.GetBytes(upstream.lastBody, "input.0.arguments").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.input").Exists()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.1.type").String()) + + var outputDone, completed []byte + for _, event := range events { + switch gjson.GetBytes(event, "type").String() { + case "response.output_item.done": + outputDone = event + case "response.completed": + completed = event + } + } + require.NotEmpty(t, outputDone) + require.Equal(t, "custom_tool_call", gjson.GetBytes(outputDone, "item.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(outputDone, "item.input").String()) + require.False(t, gjson.GetBytes(outputDone, "item.arguments").Exists()) + require.NotEmpty(t, completed) + require.Equal(t, "custom_tool_call", gjson.GetBytes(completed, "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(completed, "response.output.0.input").String()) + require.True(t, result.wsReplayInputExists) + require.Len(t, result.wsReplayInput, 1) + require.Equal(t, "custom_tool_call", gjson.GetBytes(result.wsReplayInput[0], "type").String()) + require.Equal(t, "pwd", gjson.GetBytes(result.wsReplayInput[0], "input").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyRestoresClientToolsInResponseDone(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","status":"in_progress"}}`, + ``, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}`, + ``, + `data: {"type":"response.done","sequence_number":2,"response":{"id":"resp_tools","status":"completed","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5764, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{ + "type":"response.create","model":"gpt-5","stream":true, + "tools":[{"type":"custom","name":"exec","description":"Run a command"}], + "input":"run pwd" + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, events, 4) + terminal := events[len(events)-1] + require.Equal(t, "response.done", gjson.GetBytes(terminal, "type").String()) + require.Equal(t, int64(3), gjson.GetBytes(terminal, "sequence_number").Int()) + require.Equal(t, "custom_tool_call", gjson.GetBytes(terminal, "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(terminal, "response.output.0.input").String()) + require.False(t, gjson.GetBytes(terminal, "response.output.0.arguments").Exists()) + require.True(t, result.wsReplayInputExists) + require.Len(t, result.wsReplayInput, 1) + require.Equal(t, "custom_tool_call", gjson.GetBytes(result.wsReplayInput[0], "type").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnGrokPromotesDiscoveryAndRestoresNamespaceSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","status":"in_progress"}}`, + "", + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}"}`, + "", + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}","status":"completed"}}`, + "", + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_spawn","status":"completed","output":[{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 5765, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"base_url": xai.DefaultCLIBaseURL}, + } + payload := []byte(`{ + "type":"response.create","model":"grok-4.5","stream":true, + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","call_id":"call_search","arguments":{"query":"subagent"},"status":"completed"}, + {"type":"tool_search_output","call_id":"call_search","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", payload, len(payload), + "grok-4.5", "", "", "", "", 1, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "multi_agent_v1__spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.name").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.1.type").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.tools").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.status").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.execution").Exists()) + state, ok := openAIWSHTTPBridgeToolStateFromContext(c) + require.True(t, ok) + require.Equal(t, "multi_agent_v1", state.ClientMapping.NamespaceTools["multi_agent_v1__spawn_agent"].Namespace) + require.Len(t, events, 4) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[0], "item.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[0], "item.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[2], "item.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[2], "item.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[3], "response.output.0.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[3], "response.output.0.namespace").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnGrokInheritsToolSearchAndPromotesFollowupDiscovery(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_first\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + secondSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_second\",\"output\":[{\"type\":\"function_call\",\"id\":\"item_spawn\",\"call_id\":\"call_spawn\",\"name\":\"multi_agent_v1__spawn_agent\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(firstSSE))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(secondSSE))}, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 5766, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"base_url": xai.DefaultCLIBaseURL, "subscription_tier": "free"}, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + first := []byte(`{"type":"response.create","model":"grok-4.5","stream":true,"tools":[{"type":"tool_search"}],"input":"discover tools"}`) + _, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", first, len(first), + "grok-4.5", "", "", "", "grok-ws-cache", 1, func([]byte) error { return nil }, + ) + require.NoError(t, err) + + second := []byte(`{ + "type":"response.create","model":"grok-4.5","stream":true, + "input":[{"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]}] + }`) + var events [][]byte + _, err = svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", second, len(second), + "grok-4.5", "", "", "", "grok-ws-cache", 2, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "tool_search", gjson.GetBytes(upstream.bodies[1], "tools.0.name").String()) + require.Equal(t, "multi_agent_v1__spawn_agent", gjson.GetBytes(upstream.bodies[1], "tools.1.name").String()) + require.NotEqual(t, grokFreeCacheDisabledToolChoice, gjson.GetBytes(upstream.bodies[1], "tool_choice").String()) + require.Equal(t, "grok-ws-cache", gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.0.type").String()) + require.Len(t, events, 1) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[0], "response.output.0.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[0], "response.output.0.namespace").String()) +} + +func TestOpenAIWSHTTPBridgeAPIKeyReusesClientToolMappingWhenFollowupOmitsTools(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstSSEBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_custom_first","model":"gpt-5.6-sol","output":[{"type":"function_call","id":"fc_custom_1","call_id":"call_custom_1","name":"exec","arguments":"{\"input\":\"pwd\"}"}],"usage":{"input_tokens":9,"output_tokens":1}}}`, + "", + }, "\n") + secondSSEBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_custom_second","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(firstSSEBody))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(secondSSEBody))}, + }} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.HTTPBridgeEnabled = true + cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes = 1 + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + svc := &OpenAIGatewayService{ + cfg: cfg, httpUpstream: upstream, cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 9001, Name: "api-key-custom-followup", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-upstream"}, Extra: map[string]any{"responses_websockets_v2_enabled": true}, + Concurrency: 1, Status: StatusActive, Schedulable: true, + } + + errCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + errCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + errCh <- err + return + } + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = r.Clone(r.Context()) + errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeMessage := func(payload string) { + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + defer cancelWrite() + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))) + } + readMessage := func() []byte { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + defer cancelRead() + messageType, event, readErr := clientConn.Read(readCtx) + require.NoError(t, readErr) + require.Equal(t, coderws.MessageText, messageType) + return event + } + + writeMessage(`{"type":"response.create","model":"gpt-5.6-sol","stream":true,"tools":[{"type":"custom","name":"exec"}],"input":"run pwd"}`) + firstEvent := readMessage() + require.Equal(t, "response.completed", gjson.GetBytes(firstEvent, "type").String()) + require.Equal(t, "custom_tool_call", gjson.GetBytes(firstEvent, "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(firstEvent, "response.output.0.input").String()) + + writeMessage(`{"type":"response.create","model":"gpt-5.6-sol","stream":true,"previous_response_id":"resp_custom_first","input":[{"type":"custom_tool_call_output","id":"ctco_client_output_1","call_id":"call_custom_1","output":"ok"}]}`) + secondEvent := readMessage() + require.Equal(t, "response.completed", gjson.GetBytes(secondEvent, "type").String()) + + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case proxyErr := <-errCh: + require.NoError(t, proxyErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for websocket bridge proxy to finish") + } + + require.Len(t, upstream.bodies, 2) + firstTools := gjson.GetBytes(upstream.bodies[0], "tools").Array() + require.Len(t, firstTools, 1) + require.Equal(t, "function", firstTools[0].Get("type").String()) + secondTools := gjson.GetBytes(upstream.bodies[1], "tools").Array() + require.Len(t, secondTools, 1) + require.Equal(t, "function", secondTools[0].Get("type").String()) + require.Equal(t, "exec", secondTools[0].Get("name").String()) + secondInput := gjson.GetBytes(upstream.bodies[1], "input").Array() + require.Len(t, secondInput, 3) + require.Equal(t, "run pwd", secondInput[0].String()) + require.Equal(t, "function_call", secondInput[1].Get("type").String()) + require.Equal(t, "fc_custom_1", secondInput[1].Get("id").String()) + require.JSONEq(t, `{"input":"pwd"}`, secondInput[1].Get("arguments").String()) + require.False(t, secondInput[1].Get("input").Exists()) + require.Equal(t, "function_call_output", secondInput[2].Get("type").String()) + require.False(t, secondInput[2].Get("id").Exists()) +} + +func TestOpenAIWSHTTPBridgeFullCustomToolHistoryWithoutPreviousResponseIDDoesNotReplay(t *testing.T) { + gin.SetMode(gin.TestMode) + + completed := func(responseID string, output string) string { + return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.1\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_1", `[{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}]`)))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_2", `[]`)))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_3", `[]`)))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_4", `[]`)))}, + }} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.HTTPBridgeEnabled = true + cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes = 1 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + svc := &OpenAIGatewayService{ + cfg: cfg, httpUpstream: upstream, cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 9002, Name: "oauth-full-context", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, Extra: map[string]any{"responses_websockets_v2_enabled": true}, + Concurrency: 1, Status: StatusActive, Schedulable: true, + } + + errCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + errCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + errCh <- err + return + } + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = r.Clone(r.Context()) + errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "test-token", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeAndRead := func(payload string) { + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))) + cancelWrite() + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, event, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + } + + writeAndRead(`{"type":"response.create","model":"gpt-5.1","input":"run pwd"}`) + writeAndRead(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_1","input":[{"role":"user","content":"continue without tool output"}]}`) + fullContext := `{"type":"response.create","model":"gpt-5.1","input":[{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"},{"type":"custom_tool_call_output","call_id":"call_1","output":"/tmp"},{"role":"user","content":"continue"}]}` + writeAndRead(fullContext) + writeAndRead(fullContext) + + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case proxyErr := <-errCh: + require.NoError(t, proxyErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for websocket bridge proxy to finish") + } + + require.Len(t, upstream.bodies, 4) + orphanInput := gjson.GetBytes(upstream.bodies[1], "input").Array() + require.Len(t, orphanInput, 2) + require.Equal(t, "run pwd", orphanInput[0].String()) + require.Equal(t, "user", orphanInput[1].Get("role").String()) + for _, body := range upstream.bodies[2:] { + input := gjson.GetBytes(body, "input").Array() + require.Len(t, input, 3) + require.Equal(t, "custom_tool_call", input[0].Get("type").String()) + require.Equal(t, "call_1", input[0].Get("call_id").String()) + require.Equal(t, "custom_tool_call_output", input[1].Get("type").String()) + require.Equal(t, "call_1", input[1].Get("call_id").String()) + } +} + +func TestOpenAIWSHTTPBridgeObjectToolOutputWithoutPreviousResponseIDReplaysMatchingCall(t *testing.T) { + gin.SetMode(gin.TestMode) + + completed := func(responseID string, output string) string { + return "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"" + responseID + "\",\"model\":\"gpt-5.1\",\"output\":" + output + ",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_1", `[{"type":"custom_tool_call","id":"item_1","call_id":"call_1","name":"exec","input":"pwd"}]`)))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed("resp_2", `[]`)))}, + }} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.HTTPBridgeEnabled = true + cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes = 1 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + svc := &OpenAIGatewayService{ + cfg: cfg, httpUpstream: upstream, cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 9003, Name: "oauth-output-only", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, Extra: map[string]any{"responses_websockets_v2_enabled": true}, + Concurrency: 1, Status: StatusActive, Schedulable: true, + } + + errCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + errCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + errCh <- err + return + } + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = r.Clone(r.Context()) + errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "test-token", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeAndRead := func(payload string) { + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))) + cancelWrite() + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, event, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + } + + writeAndRead(`{"type":"response.create","model":"gpt-5.1","input":"run pwd"}`) + writeAndRead(`{"type":"response.create","model":"gpt-5.1","input":{"type":"custom_tool_call_output","call_id":"call_1","output":"/tmp"}}`) + + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case proxyErr := <-errCh: + require.NoError(t, proxyErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for websocket bridge proxy to finish") + } + + require.Len(t, upstream.bodies, 2) + secondInput := gjson.GetBytes(upstream.bodies[1], "input").Array() + require.Len(t, secondInput, 3) + require.Equal(t, "custom_tool_call", secondInput[1].Get("type").String()) + require.Equal(t, "call_1", secondInput[1].Get("call_id").String()) + require.Equal(t, "custom_tool_call_output", secondInput[2].Get("type").String()) + require.Equal(t, "call_1", secondInput[2].Get("call_id").String()) } func TestOpenAIWSHTTPBridgeDecisionKeepsSmallFramesOnWS(t *testing.T) { @@ -175,6 +833,52 @@ func TestProxyOpenAIWSHTTPBridgeTurnHTTPStatusFailoverSafety(t *testing.T) { } } +func TestProxyOpenAIWSHTTPBridgeTurnRetriesRejectedFieldBeforeClientOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamSequenceRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"error":{"type":"invalid_request_error","code":"invalid_parameter","param":"truncation","message":"Unsupported parameter: truncation"}}`, + )), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_retry\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", + )), + }, + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 91, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi","truncation":"auto"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 2, upstream.callCount) + require.Len(t, upstream.bodies, 2) + require.True(t, gjson.GetBytes(upstream.bodies[0], "truncation").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists()) + require.Len(t, writes, 1) + require.Equal(t, "response.completed", gjson.GetBytes(writes[0], "type").String()) +} + func TestProxyOpenAIWSHTTPBridgeTurnSSEErrorFailoverSafety(t *testing.T) { gin.SetMode(gin.TestMode) @@ -205,17 +909,10 @@ func TestProxyOpenAIWSHTTPBridgeTurnSSEErrorFailoverSafety(t *testing.T) { ) var failoverErr *UpstreamFailoverError - if turn == 1 { - require.Nil(t, result) - require.ErrorAs(t, err, &failoverErr) - require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) - require.Empty(t, writes) - } else { - require.NotNil(t, result) - require.Error(t, err) - require.False(t, errors.As(err, &failoverErr)) - require.Len(t, writes, 1) - } + require.Nil(t, result) + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.Empty(t, writes) }) } } @@ -239,10 +936,9 @@ func TestProxyOpenAIWSHTTPBridgeTurnRewritesCapacityShedCodeForClient(t *testing wantErr: true, }, { - // response.failed 不走 error 事件分支:即便 turn 1 也会被当终止事件 - // 原样转发(不 failover),因此改写必须在这里同样生效。 - name: "turn1_bare_response_failed", - turn: 1, + // 后续 turn 不允许 replay,容量错误必须改写后交给客户端重试。 + name: "turn2_bare_response_failed", + turn: 2, body: "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_shed\",\"status\":\"failed\",\"error\":{\"code\":\"server_is_overloaded\",\"message\":\"Our servers are currently overloaded. Please try again later.\"}}}\n\n", }, } @@ -283,6 +979,187 @@ func TestProxyOpenAIWSHTTPBridgeTurnRewritesCapacityShedCodeForClient(t *testing } } +func TestProxyOpenAIWSHTTPBridgeTurnBareErrorUsesAuthoritativeFailed(t *testing.T) { + gin.SetMode(gin.TestMode) + body := strings.Join([]string{ + `data: {"type":"response.output_text.delta","response_id":"resp_failed","delta":"partial"}`, + ``, + `data: {"type":"error","error":{"status_code":403,"code":"workspace_suspended","message":"workspace is suspended"}}`, + ``, + `data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","usage":{"input_tokens":9,"output_tokens":2},"error":{"status_code":403,"code":"workspace_suspended","message":"workspace is suspended"}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}} + repo := &openAIStream403AccountRepo{} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn(context.Background(), c, account, "sk-test", payload, len(payload), "gpt-5", "", "", "", "", 2, func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 2, result.Usage.OutputTokens) + require.Equal(t, 1, repo.setErrorCalls) + require.Len(t, writes, 2) + require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[0], "type").String()) + require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnBareErrorEOFSynthesizesFailed(t *testing.T) { + gin.SetMode(gin.TestMode) + body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_eof\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn(context.Background(), c, account, "sk-test", payload, len(payload), "gpt-5", "", "", "", "", 2, func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }) + + require.EqualError(t, err, "bad request") + require.NotNil(t, result) + require.Equal(t, "response.failed", result.UpstreamTerminalEvent) + require.Len(t, writes, 2) + require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) + require.Equal(t, "failed", gjson.GetBytes(writes[1], "response.status").String()) + require.Equal(t, "resp_eof", gjson.GetBytes(writes[1], "response.id").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnBareErrorFollowedByCompletedUsesCompleted(t *testing.T) { + gin.SetMode(gin.TestMode) + body := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_recovered","status":"in_progress"}}`, + ``, + `data: {"type":"error","error":{"code":"transient","message":"retrying"}}`, + ``, + `data: {"type":"response.completed","response":{"id":"resp_recovered","status":"completed","output":[],"usage":{"input_tokens":8,"output_tokens":4}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 113, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn(context.Background(), c, account, "sk-test", payload, len(payload), "gpt-5", "", "", "", "", 2, func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "response.completed", result.UpstreamTerminalEvent) + require.Equal(t, 8, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Len(t, writes, 2) + require.Equal(t, "response.created", gjson.GetBytes(writes[0], "type").String()) + require.Equal(t, "response.completed", gjson.GetBytes(writes[1], "type").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataBeforeCapacityFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + body := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_shed"}}`, + "", + `data: {"type":"response.in_progress","response":{"id":"resp_shed"}}`, + "", + `data: {"type":"response.failed","response":{"id":"resp_shed","status":"failed","error":{"message":"Our servers are currently overloaded. Please try again later."}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"X-Request-Id": []string{"rid-ws-bridge-capacity"}}, + Body: io.NopCloser(strings.NewReader(body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.Empty(t, writes) +} + +func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + logSink, restore := captureStructuredLog(t) + defer restore() + body := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_partial"}}`, + "", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + `data: {"type":"response.failed","response":{"id":"resp_partial","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"X-Request-Id": []string{"rid-ws-bridge-post-output"}}, + Body: io.NopCloser(strings.NewReader(body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.NotNil(t, result) + require.NoError(t, err) + require.Len(t, writes, 3) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Contains(t, string(writes[2]), `"code":"server_error"`) + require.NotContains(t, string(writes[2]), "server_is_overloaded") + require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output")) + require.True(t, logSink.ContainsFieldValue("path", "ws_http_bridge")) +} + func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) { gin.SetMode(gin.TestMode) @@ -294,10 +1171,10 @@ func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) { }{ {name: "done_without_events_fails_over", body: "data: [DONE]\n\n", wantFailover: true}, { - name: "created_then_done_is_truncated_not_success", + name: "created_then_done_fails_over_before_semantic_output", body: "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_truncated\"}}\n\n" + "data: [DONE]\n\n", - wantWrites: 1, + wantFailover: true, }, } for _, tt := range tests { @@ -378,7 +1255,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { Concurrency: 1, Status: StatusActive, } - payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi"}`) + payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","parallel_tool_calls":true}`) type bridgeResult struct { result *OpenAIForwardResult @@ -464,6 +1341,8 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T) { @@ -570,11 +1449,13 @@ func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t require.Len(t, events, 2) require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists()) tools := gjson.GetBytes(upstream.lastBody, "tools").Array() - require.Len(t, tools, 3) + require.Len(t, tools, 4) require.Equal(t, "function", tools[0].Get("type").String()) require.Equal(t, "lookup", tools[0].Get("name").String()) require.Equal(t, "web_search", tools[1].Get("type").String()) - require.Equal(t, "x_search", tools[2].Get("type").String()) + require.Equal(t, "function", tools[2].Get("type").String()) + require.Equal(t, "apply_patch", tools[2].Get("name").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists()) @@ -736,7 +1617,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridgeAndPreservesMa require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.bodies[0], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[1], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[2], "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) diff --git a/backend/internal/service/openai_ws_ops_failure_test.go b/backend/internal/service/openai_ws_ops_failure_test.go new file mode 100644 index 0000000000..02c122c46d --- /dev/null +++ b/backend/internal/service/openai_ws_ops_failure_test.go @@ -0,0 +1,57 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestMarkOpenAIWSClientVisibleFailure_ResponseFailedNestedError(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + markOpenAIWSClientVisibleFailure(c, "response.failed", []byte(`{"type":"response.failed","response":{"error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"too long","status_code":400}}}`)) + + got, ok := GetOpsStreamError(c) + require.True(t, ok) + require.True(t, got.CountTowardsSLA) + require.Equal(t, http.StatusBadRequest, got.IntendedStatus) + require.Equal(t, "invalid_request_error", got.ErrType) + require.Equal(t, "context_length_exceeded", got.Code) + require.Equal(t, "too long", got.Message) +} + +func TestMarkOpenAIWSClientVisibleFailure_ErrorAndSuccessBoundary(t *testing.T) { + t.Run("error", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + markOpenAIWSClientVisibleFailure(c, "error", []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`)) + got, ok := GetOpsStreamError(c) + require.True(t, ok) + require.Equal(t, http.StatusTooManyRequests, got.IntendedStatus) + }) + + t.Run("completed does not mark", func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + markOpenAIWSClientVisibleFailure(c, "response.completed", []byte(`{"type":"response.completed"}`)) + _, ok := GetOpsStreamError(c) + require.False(t, ok) + }) +} + +func TestSetOpsUpstreamModelStoresOnlyTrimmedModelSlug(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + SetOpsUpstreamModel(c, " gpt-5.6-sol ") + value, ok := c.Get(OpsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", value) + + SetOpsUpstreamModel(c, " ") + value, ok = c.Get(OpsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "gpt-5.6-sol", value) + + ClearOpsUpstreamModel(c) + value, ok = c.Get(OpsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "", value) +} diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go index bb6e5ebde6..16be2bd4c6 100644 --- a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -60,18 +60,13 @@ func startPassthroughHookRecordingServer( return server, serverErr } -// TestPassthroughIngressNeverCallsBeforeTurn 钉死 ws_v2 透传 ingress 与 handler -// 侧 turn 定价的耦合:透传 relay 只回调 AfterTurn,没有任何 turn 起始回调, -// 因此 hooks.BeforeTurn 永远不会触发。 +// TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn 钉死 +// ws_v2 透传 ingress 与 handler 侧 turn 定价的耦合:透传 relay 不触发 +// BeforeTurn,但会在每个 AfterTurn 前通过 TurnStarted 报告同一 turn 的开始时刻。 // -// handler 依赖这一点:openAIWSTurnPricing 零值起步,透传连接的每个 turn 都拿 -// 不到冻结的 pricingAt,RecordUsage 回退到记录时刻——与引入分组利润控制前的 -// 基线一致。若把 turn 定价初始化成建连时刻,透传连接的所有 turn 就会被钉死在 -// 建连时的高峰因子,客户端峰前建连保活即可全程按谷价结算。 -// -// 若本断言因为透传补齐了 turn 起始回调而失败:这是好事,请同步复核 -// openAIWSTurnPricing 的零值语义与透传路径的 turn 级利润复核。 -func TestPassthroughIngressNeverCallsBeforeTurn(t *testing.T) { +// handler 的 recordTurnStart 保存该时刻,AfterTurn 再用 currentOr(turnStart) +// 作为计费 PricingAt;不触发 BeforeTurn 也意味着透传仍没有 turn 级利润复核。 +func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) @@ -81,17 +76,29 @@ func TestPassthroughIngressNeverCallsBeforeTurn(t *testing.T) { var hooksMu sync.Mutex beforeTurnCalls := 0 - afterTurnCalls := 0 + expectedTurnStartedAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + type hookEvent struct { + name string + turn int + startedAt time.Time + } + var hookEvents []hookEvent hooks := &OpenAIWSIngressHooks{ + InitialTurnStartedAt: expectedTurnStartedAt, + TurnStarted: func(turn int, startedAt time.Time) { + hooksMu.Lock() + hookEvents = append(hookEvents, hookEvent{name: "TurnStarted", turn: turn, startedAt: startedAt}) + hooksMu.Unlock() + }, BeforeTurn: func(int) error { hooksMu.Lock() beforeTurnCalls++ hooksMu.Unlock() return nil }, - AfterTurn: func(int, *OpenAIForwardResult, error) { + AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { hooksMu.Lock() - afterTurnCalls++ + hookEvents = append(hookEvents, hookEvent{name: "AfterTurn", turn: turn}) hooksMu.Unlock() }, } @@ -120,9 +127,109 @@ func TestPassthroughIngressNeverCallsBeforeTurn(t *testing.T) { } hooksMu.Lock() - gotBefore, gotAfter := beforeTurnCalls, afterTurnCalls + gotBefore := beforeTurnCalls + gotEvents := append([]hookEvent(nil), hookEvents...) hooksMu.Unlock() - require.Zero(t, gotBefore, "透传 ingress 没有 turn 起始回调,BeforeTurn 不应被调用") - require.Positive(t, gotAfter, "透传 ingress 仍应回调 AfterTurn 提交用量") + require.Zero(t, gotBefore, "透传 ingress 不应调用 BeforeTurn") + require.GreaterOrEqual(t, len(gotEvents), 2, "透传 ingress 应报告 TurnStarted 和 AfterTurn") + require.Equal(t, "TurnStarted", gotEvents[0].name) + require.Equal(t, expectedTurnStartedAt, gotEvents[0].startedAt, "TurnStarted 必须携带入口冻结的首轮开始时刻") + require.Equal(t, "AfterTurn", gotEvents[1].name) + require.Equal(t, gotEvents[0].turn, gotEvents[1].turn, "TurnStarted 后应提交同一 turn 的 AfterTurn") +} + +func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) { + testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t, coderws.MessageText) +} + +func TestPassthroughIngressFreezesBinarySubsequentTurnBeforeRequestPolicy(t *testing.T) { + testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t, coderws.MessageBinary) +} + +func testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T, secondMessageType coderws.MessageType) { + t.Helper() + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + + type turnStart struct { + turn int + startedAt time.Time + } + turnStarts := make(chan turnStart, 2) + beforeRequestEntered := make(chan time.Time, 1) + releaseBeforeRequest := make(chan struct{}) + hooks := &OpenAIWSIngressHooks{ + InitialTurnStartedAt: time.Now(), + TurnStarted: func(turn int, startedAt time.Time) { + turnStarts <- turnStart{turn: turn, startedAt: startedAt} + }, + BeforeRequest: func(turn int, _ []byte, _ string) error { + if turn == 2 { + beforeRequestEntered <- time.Now() + <-releaseBeforeRequest + } + return nil + }, + } + + server, serverErr := startPassthroughHookRecordingServer( + t, + controlCtx, + newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), + passthroughLifecycleAccount(), + hooks, + ) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String()) + firstCompleted, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "resp_first", gjson.GetBytes(firstCompleted, "response.id").String()) + select { + case first := <-turnStarts: + require.Equal(t, 1, first.turn) + case <-time.After(time.Second): + t.Fatal("first turn start was not reported") + } + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, secondMessageType, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_first"}`)) + cancelWrite() + require.NoError(t, err) + + var policyEnteredAt time.Time + select { + case policyEnteredAt = <-beforeRequestEntered: + case <-time.After(time.Second): + t.Fatal("second turn did not enter BeforeRequest") + } + close(releaseBeforeRequest) + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String()) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_second","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + secondCompleted, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "resp_second", gjson.GetBytes(secondCompleted, "response.id").String()) + + select { + case second := <-turnStarts: + require.Equal(t, 2, second.turn) + require.False(t, second.startedAt.After(policyEnteredAt), "第二轮开始时刻必须在 BeforeRequest 策略执行前冻结") + case <-time.After(time.Second): + t.Fatal("second turn start was not reported") + } + + _ = clientConn.CloseNow() + cancelControl(context.Canceled) + select { + case <-serverErr: + case <-time.After(3 * time.Second): + t.Fatal("passthrough ingress did not exit") + } } diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index 048031d640..2c62b6b4c0 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -77,7 +77,13 @@ type openAIWSAcquireRequest struct { } type openAIWSHandshakeCompatibilityKey struct { - betaFeatures string + betaFeatures string + codexInstallationID string + sessionIDHyphen string + sessionIDUnderscore string + threadID string + clientRequestID string + codexWindowID string } type openAIWSConnLease struct { @@ -855,7 +861,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque retryAcquire: accountID := req.Account.ID - compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Headers) + compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) routingAffinity := normalizeOpenAIWSRoutingAffinity(req.Headers) effectiveMaxConns := p.effectiveMaxConnsByAccount(req.Account) if effectiveMaxConns <= 0 { @@ -1015,37 +1021,39 @@ retryAcquire: p.ensureTargetIdleAsync(accountID) return lease, nil } - for _, conn := range ap.conns { - if conn == nil || conn == best || !conn.matchesHandshakeCompatibility(compatibility) || !conn.matchesRoutingAffinity(routingAffinity) { - continue - } - if conn.tryAcquire() { - connPick := time.Since(pickStartedAt) - p.recordConnPickDuration(connPick) - ap.mu.Unlock() - closeOpenAIWSConns(evicted) - if p.shouldHealthCheckConn(conn) { - if err := conn.pingWithTimeout(openAIWSConnHealthCheckTO); err != nil { - conn.close() - p.evictConn(accountID, conn.id) - if retry < 1 { - return p.acquire(ctx, req, retry+1) + if routingAffinity == "" || len(ap.conns)+ap.creating >= effectiveMaxConns { + for _, conn := range ap.conns { + if conn == nil || conn == best || !conn.matchesHandshakeCompatibility(compatibility) { + continue + } + if conn.tryAcquire() { + connPick := time.Since(pickStartedAt) + p.recordConnPickDuration(connPick) + ap.mu.Unlock() + closeOpenAIWSConns(evicted) + if p.shouldHealthCheckConn(conn) { + if err := conn.pingWithTimeout(openAIWSConnHealthCheckTO); err != nil { + conn.close() + p.evictConn(accountID, conn.id) + if retry < 1 { + return p.acquire(ctx, req, retry+1) + } + return nil, err } - return nil, err } + lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick, reused: true} + p.metrics.acquireReuseTotal.Add(1) + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) + p.ensureTargetIdleAsync(accountID) + return lease, nil } - lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick, reused: true} - p.metrics.acquireReuseTotal.Add(1) - p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) - p.ensureTargetIdleAsync(accountID) - return lease, nil } } } if !req.ForceNewConn && len(ap.conns)+ap.creating >= effectiveMaxConns { affine := p.pickLeastBusyConnWithRoutingAffinityLocked(ap, compatibility, routingAffinity) - if idle := p.pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked(ap, compatibility, routingAffinity); idle != nil { + if idle := p.pickOldestIdleConnWithoutHandshakeCompatibilityLocked(ap, compatibility); idle != nil { delete(ap.conns, idle.id) evicted = append(evicted, idle) p.metrics.scaleDownTotal.Add(1) @@ -1241,10 +1249,9 @@ func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *op return oldest } -func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked( +func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityLocked( ap *openAIWSAccountPool, compatibility openAIWSHandshakeCompatibilityKey, - routingAffinity string, ) *openAIWSConn { if ap == nil || len(ap.conns) == 0 { return nil @@ -1252,7 +1259,7 @@ func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityOrRout var oldest *openAIWSConn for _, conn := range ap.conns { if conn == nil || - (conn.matchesHandshakeCompatibility(compatibility) && conn.matchesRoutingAffinity(routingAffinity)) || + conn.matchesHandshakeCompatibility(compatibility) || conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) { continue } @@ -1800,7 +1807,7 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ } id := p.nextConnID(req.Account.ID) pooledConn := newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders) - pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Headers) + pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) pooledConn.routingAffinity = normalizeOpenAIWSRoutingAffinity(req.Headers) return pooledConn, nil } @@ -1983,7 +1990,7 @@ func cloneOpenAIWSAcquireRequestPtr(req *openAIWSAcquireRequest) *openAIWSAcquir func sameOpenAIWSPrewarmTarget(a, b openAIWSAcquireRequest) bool { return stringsTrim(a.WSURL) == stringsTrim(b.WSURL) && stringsTrim(a.ProxyURL) == stringsTrim(b.ProxyURL) && - normalizeOpenAIWSHandshakeCompatibility(a.Headers) == normalizeOpenAIWSHandshakeCompatibility(b.Headers) + normalizeOpenAIWSHandshakeCompatibility(a.Account, a.Headers) == normalizeOpenAIWSHandshakeCompatibility(b.Account, b.Headers) } func normalizeOpenAIWSBetaFeatures(headers http.Header) string { @@ -2011,10 +2018,41 @@ func normalizeOpenAIWSBetaFeatures(headers http.Header) string { return strings.Join(normalized, ",") } -func normalizeOpenAIWSHandshakeCompatibility(headers http.Header) openAIWSHandshakeCompatibilityKey { - return openAIWSHandshakeCompatibilityKey{ +func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Header) openAIWSHandshakeCompatibilityKey { + key := openAIWSHandshakeCompatibilityKey{ betaFeatures: normalizeOpenAIWSBetaFeatures(headers), } + mode := activeCodexFingerprintMode(account) + if mode == codexFingerprintOff { + return key + } + key.codexInstallationID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-installation-id") + if mode == codexFingerprintDevice { + return key + } + key.sessionIDHyphen = normalizeOpenAIWSStableIdentityHeader(headers, "session-id") + key.sessionIDUnderscore = normalizeOpenAIWSStableIdentityHeader(headers, "session_id") + key.threadID = normalizeOpenAIWSStableIdentityHeader(headers, "thread-id") + key.clientRequestID = normalizeOpenAIWSStableIdentityHeader(headers, "x-client-request-id") + key.codexWindowID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-window-id") + return key +} + +func activeCodexFingerprintMode(account *Account) codexFingerprintMode { + if account == nil || account.GetCodexFingerprintMode() == codexFingerprintOff { + return codexFingerprintOff + } + if _, ok := codexFingerprintSeed(account.Extra); !ok { + return codexFingerprintOff + } + return account.GetCodexFingerprintMode() +} + +func normalizeOpenAIWSStableIdentityHeader(headers http.Header, name string) string { + if headers == nil { + return "" + } + return strings.TrimSpace(headers.Get(name)) } func normalizeOpenAIWSRoutingAffinity(headers http.Header) string { diff --git a/backend/internal/service/openai_ws_pool_test.go b/backend/internal/service/openai_ws_pool_test.go index f04b81d40d..ada18af8d3 100644 --- a/backend/internal/service/openai_ws_pool_test.go +++ b/backend/internal/service/openai_ws_pool_test.go @@ -623,6 +623,206 @@ func TestOpenAIWSConnPool_AcquireReusesOnlyMatchingBetaFeatures(t *testing.T) { require.Equal(t, 2, dialer.DialCount()) } +func activeCodexFingerprintPoolAccountForTest(id int64) *Account { + return &Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: "11111111-1111-4111-8111-111111111111", + }, + } +} + +func stableOpenAIWSIdentityHeadersForTest() http.Header { + headers := make(http.Header) + headers.Set("X-Codex-Beta-Features", "remote_compaction_v2,responses_websockets_v2") + headers.Set("X-Codex-Installation-ID", "install-a") + headers.Set("session-id", "session-hyphen-a") + headers.Set("session_id", "session-underscore-a") + headers.Set("thread-id", "thread-a") + headers.Set("x-client-request-id", "client-request-a") + headers.Set("x-codex-window-id", "window-a") + return headers +} + +func TestOpenAIWSConnPool_AcquireReusesSameStableIdentityWithDifferentTurnMetadata(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(132) + headers := stableOpenAIWSIdentityHeadersForTest() + headers.Set("Authorization", "Bearer token-a") + headers.Set("x-codex-turn-metadata", `{"turn_id":"turn-a"}`) + + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: headers, + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + nextHeaders := stableOpenAIWSIdentityHeadersForTest() + nextHeaders.Set("Authorization", "Bearer token-b") + nextHeaders.Set("x-codex-turn-metadata", `{"turn_id":"turn-b"}`) + nextHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex;tier=priority") + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: nextHeaders, + }) + require.NoError(t, err) + require.True(t, second.Reused()) + require.Equal(t, firstConnID, second.ConnID()) + second.Release() + require.Equal(t, 1, dialer.DialCount(), "stable identity match should ignore auth, turn metadata, and soft routing hints") +} + +func TestOpenAIWSConnPool_AcquireDoesNotReuseDifferentStableIdentity(t *testing.T) { + for _, tt := range []struct { + name string + header string + value string + }{ + {name: "installation", header: "x-codex-installation-id", value: "install-b"}, + {name: "session hyphen", header: "session-id", value: "session-hyphen-b"}, + {name: "session underscore", header: "session_id", value: "session-underscore-b"}, + {name: "thread", header: "thread-id", value: "thread-b"}, + {name: "client request", header: "x-client-request-id", value: "client-request-b"}, + {name: "window", header: "x-codex-window-id", value: "window-b"}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(133) + + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: stableOpenAIWSIdentityHeadersForTest(), + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + nextHeaders := stableOpenAIWSIdentityHeadersForTest() + nextHeaders.Set(tt.header, tt.value) + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: nextHeaders, + }) + require.NoError(t, err) + require.False(t, second.Reused()) + require.NotEqual(t, firstConnID, second.ConnID()) + second.Release() + require.Equal(t, 2, dialer.DialCount()) + }) + } +} + +func TestOpenAIWSConnPool_AcquireRoutingHintRemainsSoftAffinity(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(134) + + firstHeaders := stableOpenAIWSIdentityHeadersForTest() + firstHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex") + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: firstHeaders, + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + secondHeaders := stableOpenAIWSIdentityHeadersForTest() + secondHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex;tier=priority") + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: secondHeaders, + }) + require.NoError(t, err) + require.True(t, second.Reused()) + require.Equal(t, firstConnID, second.ConnID()) + second.Release() + require.Equal(t, 1, dialer.DialCount()) +} + +func TestOpenAIWSConnPool_DeviceModeKeysOnlyInstallationIdentity(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(135) + account.Extra[codexFingerprintModeExtraKey] = "device" + + firstHeaders := stableOpenAIWSIdentityHeadersForTest() + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: firstHeaders, + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + sessionChanged := stableOpenAIWSIdentityHeadersForTest() + sessionChanged.Set("session-id", "session-hyphen-b") + sessionChanged.Set("session_id", "session-underscore-b") + sessionChanged.Set("thread-id", "thread-b") + sessionChanged.Set("x-client-request-id", "client-request-b") + sessionChanged.Set("x-codex-window-id", "window-b") + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: sessionChanged, + }) + require.NoError(t, err) + require.True(t, second.Reused()) + require.Equal(t, firstConnID, second.ConnID()) + second.Release() + + installationChanged := sessionChanged.Clone() + installationChanged.Set("x-codex-installation-id", "install-b") + third, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: installationChanged, + }) + require.NoError(t, err) + require.False(t, third.Reused()) + require.NotEqual(t, firstConnID, third.ConnID()) + third.Release() + require.Equal(t, 2, dialer.DialCount()) +} + func TestOpenAIWSConnPool_AcquireReplacesIdleConnWithDifferentBetaFeatures(t *testing.T) { cfg := &config.Config{} cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go index 32779a23f3..6ee33103d7 100644 --- a/backend/internal/service/openai_ws_protocol_forward_test.go +++ b/backend/internal/service/openai_ws_protocol_forward_test.go @@ -178,7 +178,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressStaysHTTPWhenWSEnabled(t *testi require.NotNil(t, result) require.False(t, result.OpenAIWSMode, "HTTP 入站应保持 HTTP 转发") require.NotNil(t, upstream.lastReq, "HTTP 入站应命中 HTTP 上游") - require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists(), "HTTP 路径应沿用原逻辑移除 previous_response_id") + require.Equal(t, "resp_http_keep", gjson.GetBytes(upstream.lastBody, "previous_response_id").String(), "API-key HTTP must preserve official Responses continuation") decision, _ := c.Get("openai_ws_transport_decision") reason, _ := c.Get("openai_ws_transport_reason") @@ -257,11 +257,11 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesInvalidEncryptedContentO firstBody := upstream.bodies[0] secondBody := upstream.bodies[1] - require.False(t, gjson.GetBytes(firstBody, "previous_response_id").Exists(), "HTTP 首次请求仍应沿用原逻辑移除 previous_response_id") + require.Equal(t, "resp_http_retry", gjson.GetBytes(firstBody, "previous_response_id").String(), "API-key HTTP must preserve continuation on the first attempt") require.True(t, gjson.GetBytes(firstBody, "input.0.encrypted_content").Exists(), "首次请求不应做发送前预清理") require.Equal(t, "keep me", gjson.GetBytes(firstBody, "input.0.summary.0.text").String()) - require.False(t, gjson.GetBytes(secondBody, "previous_response_id").Exists(), "HTTP 精确重试不应重新带回 previous_response_id") + require.Equal(t, "resp_http_retry", gjson.GetBytes(secondBody, "previous_response_id").String(), "encrypted-content retry must preserve API-key continuation") require.False(t, gjson.GetBytes(secondBody, "input.0.encrypted_content").Exists(), "精确重试应移除 reasoning.encrypted_content") require.Equal(t, "keep me", gjson.GetBytes(secondBody, "input.0.summary.0.text").String(), "精确重试应保留有效 reasoning summary") require.Equal(t, "input_text", gjson.GetBytes(secondBody, "input.1.type").String(), "非 reasoning input 应保持原样") @@ -356,7 +356,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesWrappedInvalidEncryptedC require.Equal(t, "client_protocol_http", reason) } -func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(t *testing.T) { +func TestOpenAIGatewayService_Forward_APIKeyHTTPPreservesPreviousResponseIDWhenWSDisabled(t *testing.T) { gin.SetMode(gin.TestMode) wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) @@ -409,7 +409,7 @@ func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(t * result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) - require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) + require.Equal(t, "resp_123", gjson.GetBytes(upstream.lastBody, "previous_response_id").String()) } func TestOpenAIGatewayService_Forward_WSv2Dial426FallbackHTTP(t *testing.T) { diff --git a/backend/internal/service/openai_ws_protocol_resolver.go b/backend/internal/service/openai_ws_protocol_resolver.go index e11975bf37..a34dc543d9 100644 --- a/backend/internal/service/openai_ws_protocol_resolver.go +++ b/backend/internal/service/openai_ws_protocol_resolver.go @@ -56,7 +56,7 @@ func (r *defaultOpenAIWSProtocolResolver) Resolve(account *Account) OpenAIWSProt if !wsCfg.Enabled { return openAIWSHTTPDecision("global_disabled") } - if account.IsOpenAIOAuth() { + if account.IsOpenAIOAuthLike() { if !wsCfg.OAuthEnabled { return openAIWSHTTPDecision("oauth_disabled") } diff --git a/backend/internal/service/openai_ws_protocol_resolver_test.go b/backend/internal/service/openai_ws_protocol_resolver_test.go index 6d18604d03..32716a2c70 100644 --- a/backend/internal/service/openai_ws_protocol_resolver_test.go +++ b/backend/internal/service/openai_ws_protocol_resolver_test.go @@ -29,6 +29,32 @@ func TestOpenAIWSProtocolResolver_Resolve(t *testing.T) { require.Equal(t, "ws_v2_enabled", decision.Reason) }) + t.Run("OpenAI setup token复用OAuth开关", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeSetupToken, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_enabled": true, + }, + } + decision := NewOpenAIWSProtocolResolver(baseCfg).Resolve(account) + require.Equal(t, OpenAIUpstreamTransportResponsesWebsocketV2, decision.Transport) + require.Equal(t, "ws_v2_enabled", decision.Reason) + }) + + t.Run("非OpenAI setup token不进入OAuth WS", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeSetupToken, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_enabled": true, + }, + } + decision := NewOpenAIWSProtocolResolver(baseCfg).Resolve(account) + require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport) + require.Equal(t, "platform_not_openai", decision.Reason) + }) + t.Run("v2关闭时回退v1", func(t *testing.T) { cfg := *baseCfg cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = false @@ -61,6 +87,15 @@ func TestOpenAIWSProtocolResolver_Resolve(t *testing.T) { require.Equal(t, "account_force_http", decision.Reason) }) + t.Run("全局强制HTTP无需启用mode router", func(t *testing.T) { + cfg := *baseCfg + cfg.Gateway.OpenAIWS.ForceHTTP = true + + decision := NewOpenAIWSProtocolResolver(&cfg).Resolve(openAIOAuthEnabled) + require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport) + require.Equal(t, "global_force_http", decision.Reason) + }) + t.Run("全局关闭保持HTTP", func(t *testing.T) { cfg := *baseCfg cfg.Gateway.OpenAIWS.Enabled = false diff --git a/backend/internal/service/openai_ws_ratelimit_signal_test.go b/backend/internal/service/openai_ws_ratelimit_signal_test.go index 9d1fdaf765..13346cc460 100644 --- a/backend/internal/service/openai_ws_ratelimit_signal_test.go +++ b/backend/internal/service/openai_ws_ratelimit_signal_test.go @@ -552,3 +552,30 @@ func TestOpenAIWSErrorHTTPStatusFromRaw_UsageLimitReachedIs429(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, openAIWSErrorHTTPStatusFromRaw("", "usage_limit_reached")) require.Equal(t, http.StatusTooManyRequests, openAIWSErrorHTTPStatusFromRaw("rate_limit_exceeded", "")) } + +func TestOpenAIWSRateLimitFailoverError_OAuthKeepsSameAccountDeadline(t *testing.T) { + svc := &OpenAIGatewayService{} + headers := http.Header{"Retry-After": []string{"30"}} + body := []byte(`{"error":{"type":"rate_limit_error","message":"limited"}}`) + + oauthErr := svc.newOpenAIWSRateLimitFailoverError(&Account{ + ID: 904, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }, headers, body, "limited") + require.True(t, oauthErr.RetryableOnSameAccount) + require.False(t, oauthErr.SameAccountRetryDeadline.IsZero()) + require.Positive(t, oauthErr.SameAccountRetryDelay) + require.LessOrEqual(t, oauthErr.SameAccountRetryDelay, openAIOAuth429MaxRetryDelay) + require.Equal(t, body, oauthErr.ResponseBody) + require.Equal(t, "30", oauthErr.ResponseHeaders.Get("Retry-After")) + + apiKeyErr := svc.newOpenAIWSRateLimitFailoverError(&Account{ + ID: 905, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + }, headers, body, "limited") + require.False(t, apiKeyErr.RetryableOnSameAccount) + require.True(t, apiKeyErr.SameAccountRetryDeadline.IsZero()) + require.Zero(t, apiKeyErr.SameAccountRetryDelay) +} diff --git a/backend/internal/service/openai_ws_session_preemption.go b/backend/internal/service/openai_ws_session_preemption.go new file mode 100644 index 0000000000..54da7f7a1d --- /dev/null +++ b/backend/internal/service/openai_ws_session_preemption.go @@ -0,0 +1,275 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +var errOpenAIWSSessionPreempted = errors.New("openai ws session preempted by newer request") + +const ( + openAIWSSessionPreemptOwnerTTL = 2 * time.Hour + openAIWSSessionPreemptWatchInterval = 2 * time.Second + openAIWSSessionPreemptCachePrefix = "wspreempt:" +) + +// OpenAIWSSessionPreemptionCache is an optional GatewayCache capability. The +// production Redis cache implements all operations atomically; cache stubs do +// not need to implement it for ordinary gateway tests. +type OpenAIWSSessionPreemptionCache interface { + ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error) + CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error) + CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error) +} + +func NewOpenAIWSSessionPreemptedError() error { + return errOpenAIWSSessionPreempted +} + +type openAIWSSessionPreemptKey struct { + groupID int64 + apiKeyID int64 + sessionHash string +} + +type openAIWSSessionPreemptContextKey struct{} + +// BeginOpenAIWSIngressSessionPreemption keeps a persistent inbound WS session +// registered across upstream retry attempts. Nested forwarding calls reuse the +// registration so returning from one attempt cannot create a preemption gap. +func (s *OpenAIGatewayService) BeginOpenAIWSIngressSessionPreemption( + ctx context.Context, + c *gin.Context, + account *Account, + firstClientMessage []byte, +) (context.Context, func(), bool) { + if ctx == nil { + ctx = context.Background() + } + if armed, _ := ctx.Value(openAIWSSessionPreemptContextKey{}).(bool); armed { + return ctx, func() {}, true + } + + preemptSessionHash := "" + preemptGroupID := getOpenAIGroupIDFromContext(c) + if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth { + preemptSessionHash = s.GenerateSessionHash(c, firstClientMessage) + } + preemptCtx, cleanup, armed, preemptedPrevious := s.beginOpenAIWSSessionPreemptContext( + ctx, + account, + preemptGroupID, + getAPIKeyIDFromContext(c), + preemptSessionHash, + false, + ) + if !armed { + return ctx, func() {}, false + } + if preemptedPrevious { + if stateStore := s.getOpenAIWSStateStore(); stateStore != nil { + stateStore.DeleteSessionTurnState(preemptGroupID, preemptSessionHash) + stateStore.DeleteSessionConn(preemptGroupID, preemptSessionHash) + } + } + return context.WithValue(preemptCtx, openAIWSSessionPreemptContextKey{}, true), cleanup, true +} + +func newOpenAIWSSessionPreemptKey(groupID, apiKeyID int64, sessionHash string) (openAIWSSessionPreemptKey, bool) { + sessionHash = strings.TrimSpace(sessionHash) + if groupID <= 0 || apiKeyID <= 0 || sessionHash == "" { + return openAIWSSessionPreemptKey{}, false + } + return openAIWSSessionPreemptKey{groupID: groupID, apiKeyID: apiKeyID, sessionHash: sessionHash}, true +} + +func openAIWSSessionPreemptCacheHash(apiKeyID int64, sessionHash string) string { + return fmt.Sprintf("%s%d:%s", openAIWSSessionPreemptCachePrefix, apiKeyID, strings.TrimSpace(sessionHash)) +} + +type openAIWSSessionPreemptEntry struct { + generation uint64 + cancel func() +} + +type openAIWSSessionPreemptRegistry struct { + mu sync.Mutex + next uint64 + active map[openAIWSSessionPreemptKey]openAIWSSessionPreemptEntry +} + +func (r *openAIWSSessionPreemptRegistry) Begin(key openAIWSSessionPreemptKey, cancel func()) (cleanup func(), preemptedPrevious bool) { + if r == nil || strings.TrimSpace(key.sessionHash) == "" { + return func() {}, false + } + r.mu.Lock() + if r.active == nil { + r.active = make(map[openAIWSSessionPreemptKey]openAIWSSessionPreemptEntry) + } + r.next++ + generation := r.next + previous, hadPrevious := r.active[key] + r.active[key] = openAIWSSessionPreemptEntry{generation: generation, cancel: cancel} + r.mu.Unlock() + if hadPrevious && previous.cancel != nil { + previous.cancel() + } + return func() { + r.mu.Lock() + defer r.mu.Unlock() + current, ok := r.active[key] + if ok && current.generation == generation { + delete(r.active, key) + } + }, hadPrevious +} + +func (s *OpenAIGatewayService) beginOpenAIWSSessionPreemptContext( + ctx context.Context, + account *Account, + groupID, apiKeyID int64, + sessionHash string, + httpIngressWSOneShot bool, +) (context.Context, func(), bool, bool) { + if ctx == nil { + ctx = context.Background() + } + if s == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth || httpIngressWSOneShot { + return ctx, func() {}, false, false + } + key, ok := newOpenAIWSSessionPreemptKey(groupID, apiKeyID, sessionHash) + if !ok { + return ctx, func() {}, false, false + } + + preemptCtx, cancel := context.WithCancelCause(ctx) + ownerToken := uuid.NewString() + var preemptOnce sync.Once + preempt := func() { + preemptOnce.Do(func() { + if stateStore := s.getOpenAIWSStateStore(); stateStore != nil { + stateStore.DeleteSessionTurnState(key.groupID, key.sessionHash) + stateStore.DeleteSessionConn(key.groupID, key.sessionHash) + } + cancel(errOpenAIWSSessionPreempted) + }) + } + previousRemoteOwner, remoteClaimed := s.claimOpenAIWSSessionPreemptOwner(ctx, key, ownerToken) + preemptedPrevious := remoteClaimed && previousRemoteOwner != "" && previousRemoteOwner != ownerToken + cleanupLocal, hadLocalPrevious := s.openaiWSSessionPreemptions.Begin(key, preempt) + preemptedPrevious = preemptedPrevious || hadLocalPrevious + stopWatch := func() {} + if remoteClaimed { + stopWatch = s.watchOpenAIWSSessionPreemptOwner(preemptCtx, key, ownerToken, preempt) + } + + return preemptCtx, func() { + stopWatch() + cleanupLocal() + if remoteClaimed { + s.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, ownerToken) + } + cancel(nil) + }, true, preemptedPrevious +} + +func (s *OpenAIGatewayService) openAIWSSessionPreemptionCache() OpenAIWSSessionPreemptionCache { + if s == nil || s.cache == nil { + return nil + } + cache, _ := s.cache.(OpenAIWSSessionPreemptionCache) + return cache +} + +func (s *OpenAIGatewayService) claimOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string) (string, bool) { + cache := s.openAIWSSessionPreemptionCache() + if cache == nil || strings.TrimSpace(ownerToken) == "" { + return "", false + } + cacheCtx, cancel := context.WithTimeout(ctx, openAIWSStateStoreRedisTimeout) + defer cancel() + previous, err := cache.ClaimOpenAIResponsesSessionWindow( + cacheCtx, + key.groupID, + openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash), + []byte(strings.TrimSpace(ownerToken)), + openAIWSSessionPreemptOwnerTTL, + ) + if err != nil { + return "", false + } + return strings.TrimSpace(string(previous)), true +} + +func (s *OpenAIGatewayService) releaseOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string) { + cache := s.openAIWSSessionPreemptionCache() + if cache == nil || strings.TrimSpace(ownerToken) == "" { + return + } + cacheCtx, cancel := context.WithTimeout(ctx, openAIWSStateStoreRedisTimeout) + defer cancel() + _, _ = cache.CompareAndDeleteOpenAIResponsesSessionWindow( + cacheCtx, + key.groupID, + openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash), + []byte(strings.TrimSpace(ownerToken)), + ) +} + +func (s *OpenAIGatewayService) watchOpenAIWSSessionPreemptOwner(ctx context.Context, key openAIWSSessionPreemptKey, ownerToken string, onLost func()) func() { + cache := s.openAIWSSessionPreemptionCache() + if cache == nil || onLost == nil || strings.TrimSpace(ownerToken) == "" { + return func() {} + } + stopCh := make(chan struct{}) + var once sync.Once + go func() { + ticker := time.NewTicker(openAIWSSessionPreemptWatchInterval) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ctx.Done(): + return + case <-ticker.C: + cacheCtx, cancel := context.WithTimeout(context.Background(), openAIWSStateStoreRedisTimeout) + owned, err := cache.CompareAndRefreshOpenAIResponsesSessionWindow( + cacheCtx, + key.groupID, + openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash), + []byte(strings.TrimSpace(ownerToken)), + openAIWSSessionPreemptOwnerTTL, + ) + cancel() + if err == nil && !owned { + onLost() + return + } + } + } + }() + return func() { once.Do(func() { close(stopCh) }) } +} + +func isOpenAIWSSessionPreempted(ctx context.Context) bool { + return ctx != nil && errors.Is(context.Cause(ctx), errOpenAIWSSessionPreempted) +} + +func IsOpenAIWSSessionPreemptedError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, errOpenAIWSSessionPreempted) { + return true + } + var fallbackErr *openAIWSFallbackError + return errors.As(err, &fallbackErr) && fallbackErr != nil && strings.TrimPrefix(strings.TrimSpace(fallbackErr.Reason), "prewarm_") == "session_preempted" +} diff --git a/backend/internal/service/openai_ws_session_preemption_test.go b/backend/internal/service/openai_ws_session_preemption_test.go new file mode 100644 index 0000000000..9df9465944 --- /dev/null +++ b/backend/internal/service/openai_ws_session_preemption_test.go @@ -0,0 +1,183 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestOpenAIWSSessionPreemptRegistryCancelsSameScopedSessionOnly(t *testing.T) { + var registry openAIWSSessionPreemptRegistry + key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"} + other := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 12, sessionHash: "sess"} + firstCtx, firstCancel := context.WithCancel(context.Background()) + firstCleanup, replaced := registry.Begin(key, firstCancel) + require.False(t, replaced) + otherCtx, otherCancel := context.WithCancel(context.Background()) + otherCleanup, replaced := registry.Begin(other, otherCancel) + require.False(t, replaced) + secondCtx, secondCancel := context.WithCancel(context.Background()) + secondCleanup, replaced := registry.Begin(key, secondCancel) + require.True(t, replaced) + + require.ErrorIs(t, firstCtx.Err(), context.Canceled) + require.NoError(t, otherCtx.Err()) + require.NoError(t, secondCtx.Err()) + firstCleanup() + require.NoError(t, secondCtx.Err(), "stale cleanup must not remove the replacement") + + secondCleanup() + otherCleanup() +} + +type openAIWSSessionPreemptCacheStub struct { + GatewayCache + mu sync.Mutex + owners map[string][]byte +} + +func (c *openAIWSSessionPreemptCacheStub) key(groupID int64, hash string) string { + return fmt.Sprintf("%d:%s", groupID, hash) +} + +func (c *openAIWSSessionPreemptCacheStub) ClaimOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, owner []byte, _ time.Duration) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.owners == nil { + c.owners = make(map[string][]byte) + } + key := c.key(groupID, hash) + previous := append([]byte(nil), c.owners[key]...) + c.owners[key] = append([]byte(nil), owner...) + return previous, nil +} + +func (c *openAIWSSessionPreemptCacheStub) CompareAndRefreshOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte, _ time.Duration) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + return string(c.owners[c.key(groupID, hash)]) == string(expected), nil +} + +func (c *openAIWSSessionPreemptCacheStub) CompareAndDeleteOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + key := c.key(groupID, hash) + if string(c.owners[key]) != string(expected) { + return false, nil + } + delete(c.owners, key) + return true, nil +} + +func TestOpenAIWSSessionPreemptContextEligibilityAndLocalCancellation(t *testing.T) { + stateStore := NewOpenAIWSStateStore(nil) + svc := &OpenAIGatewayService{openaiWSStateStore: stateStore} + oauth := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKey := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + grok := &Account{ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth} + + _, cleanup, armed, _ := svc.beginOpenAIWSSessionPreemptContext(context.Background(), apiKey, 7, 11, "sess", false) + cleanup() + require.False(t, armed) + _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), grok, 7, 11, "sess", false) + cleanup() + require.False(t, armed) + _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", true) + cleanup() + require.False(t, armed, "HTTP-ingress one-shot must not participate") + + firstCtx, firstCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false) + require.True(t, armed) + require.False(t, replaced) + stateStore.BindSessionTurnState(7, "sess", "turn-state", time.Hour) + stateStore.BindSessionConn(7, "sess", "conn-1", time.Hour) + _, secondCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false) + require.True(t, armed) + require.True(t, replaced) + require.True(t, isOpenAIWSSessionPreempted(firstCtx)) + require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx))) + _, turnStateExists := stateStore.GetSessionTurnState(7, "sess") + _, sessionConnExists := stateStore.GetSessionConn(7, "sess") + require.False(t, turnStateExists) + require.False(t, sessionConnExists) + firstCleanup() + secondCleanup() +} + +func TestOpenAIWSIngressSessionPreemptionSurvivesNestedForwardCleanup(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(7) + newContext := func() *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID}) + return c + } + + svc := &OpenAIGatewayService{} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"session-1","input":"hello"}`) + + firstCtx, firstCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption( + context.Background(), newContext(), account, firstMessage, + ) + require.True(t, armed) + defer firstCleanup() + + // ProxyResponsesWebSocketFromClient enters the same helper for each upstream + // attempt. Its cleanup must not release the handler-owned registration. + nestedCtx, nestedCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption( + firstCtx, newContext(), account, firstMessage, + ) + require.True(t, armed) + require.Equal(t, firstCtx, nestedCtx) + nestedCleanup() + require.NoError(t, firstCtx.Err()) + + _, secondCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption( + context.Background(), newContext(), account, firstMessage, + ) + require.True(t, armed) + defer secondCleanup() + require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx))) +} + +func TestOpenAIWSSessionPreemptRemoteClaimAndStaleReleaseAreAtomic(t *testing.T) { + cache := &openAIWSSessionPreemptCacheStub{} + svc := &OpenAIGatewayService{cache: cache} + key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"} + + previous, ok := svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a") + require.True(t, ok) + require.Empty(t, previous) + previous, ok = svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-b") + require.True(t, ok) + require.Equal(t, "owner-a", previous) + svc.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a") + + cache.mu.Lock() + current := string(cache.owners[cache.key(key.groupID, openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash))]) + cache.mu.Unlock() + require.Equal(t, "owner-b", current, "stale cleanup must preserve the replacement owner") +} + +func TestNewOpenAIWSSessionPreemptKeyRequiresFullIsolationScope(t *testing.T) { + _, ok := newOpenAIWSSessionPreemptKey(0, 11, "sess") + require.False(t, ok) + _, ok = newOpenAIWSSessionPreemptKey(7, 0, "sess") + require.False(t, ok) + _, ok = newOpenAIWSSessionPreemptKey(7, 11, " ") + require.False(t, ok) + key, ok := newOpenAIWSSessionPreemptKey(7, 11, " sess ") + require.True(t, ok) + require.Equal(t, "sess", key.sessionHash) +} diff --git a/backend/internal/service/openai_ws_state_store.go b/backend/internal/service/openai_ws_state_store.go index d3b6891b0a..fe4ee54926 100644 --- a/backend/internal/service/openai_ws_state_store.go +++ b/backend/internal/service/openai_ws_state_store.go @@ -13,6 +13,8 @@ import ( const ( openAIWSResponseAccountCachePrefix = "openai:response:" + openAIHTTPResponseOwnerUserPrefix = "openai:http-response-owner:user:" + openAIHTTPResponseOwnerKeyPrefix = "openai:http-response-owner:key:" openAIWSStateStoreCleanupInterval = time.Minute openAIWSStateStoreCleanupMaxPerMap = 512 openAIWSStateStoreMaxEntriesPerMap = 65536 @@ -24,6 +26,12 @@ type openAIWSAccountBinding struct { expiresAt time.Time } +type openAIHTTPResponseOwnerBinding struct { + userID int64 + apiKeyID int64 + expiresAt time.Time +} + type openAIWSConnBinding struct { connID string expiresAt time.Time @@ -49,6 +57,8 @@ type OpenAIWSStateStore interface { BindResponseAccount(ctx context.Context, groupID int64, responseID string, accountID int64, ttl time.Duration) error GetResponseAccount(ctx context.Context, groupID int64, responseID string) (int64, error) DeleteResponseAccount(ctx context.Context, groupID int64, responseID string) error + BindHTTPResponseOwner(ctx context.Context, groupID int64, responseID string, userID, apiKeyID int64, ttl time.Duration) error + GetHTTPResponseOwner(ctx context.Context, groupID int64, responseID string) (userID, apiKeyID int64, found bool, err error) BindResponseConn(responseID, connID string, ttl time.Duration) GetResponseConn(responseID string) (string, bool) @@ -68,6 +78,8 @@ type defaultOpenAIWSStateStore struct { responseToAccountMu sync.RWMutex responseToAccount map[string]openAIWSAccountBinding + responseOwnerMu sync.RWMutex + responseOwners map[string]openAIHTTPResponseOwnerBinding responseToConnMu sync.RWMutex responseToConn map[string]openAIWSConnBinding sessionToTurnStateMu sync.RWMutex @@ -83,6 +95,7 @@ func NewOpenAIWSStateStore(cache GatewayCache) OpenAIWSStateStore { store := &defaultOpenAIWSStateStore{ cache: cache, responseToAccount: make(map[string]openAIWSAccountBinding, 256), + responseOwners: make(map[string]openAIHTTPResponseOwnerBinding, 256), responseToConn: make(map[string]openAIWSConnBinding, 256), sessionToTurnState: make(map[string]openAIWSTurnStateBinding, 256), sessionToConn: make(map[string]openAIWSSessionConnBinding, 256), @@ -91,6 +104,72 @@ func NewOpenAIWSStateStore(cache GatewayCache) OpenAIWSStateStore { return store } +func (s *defaultOpenAIWSStateStore) BindHTTPResponseOwner(ctx context.Context, groupID int64, responseID string, userID, apiKeyID int64, ttl time.Duration) error { + id := normalizeOpenAIWSResponseID(responseID) + if id == "" || userID <= 0 || apiKeyID <= 0 { + return nil + } + ttl = normalizeOpenAIWSTTL(ttl) + s.maybeCleanup() + + mapKey := openAIWSResponseAccountMapKey(groupID, id) + s.responseOwnerMu.Lock() + ensureBindingCapacity(s.responseOwners, mapKey, openAIWSStateStoreMaxEntriesPerMap) + s.responseOwners[mapKey] = openAIHTTPResponseOwnerBinding{ + userID: userID, apiKeyID: apiKeyID, expiresAt: time.Now().Add(ttl), + } + s.responseOwnerMu.Unlock() + + if s.cache == nil { + return nil + } + cacheCtx, cancel := withOpenAIWSStateStoreRedisTimeout(ctx) + defer cancel() + if err := s.cache.SetSessionAccountID(cacheCtx, groupID, openAIHTTPResponseOwnerCacheKey(openAIHTTPResponseOwnerUserPrefix, id), userID, ttl); err != nil { + return err + } + return s.cache.SetSessionAccountID(cacheCtx, groupID, openAIHTTPResponseOwnerCacheKey(openAIHTTPResponseOwnerKeyPrefix, id), apiKeyID, ttl) +} + +func (s *defaultOpenAIWSStateStore) GetHTTPResponseOwner(ctx context.Context, groupID int64, responseID string) (int64, int64, bool, error) { + id := normalizeOpenAIWSResponseID(responseID) + if id == "" { + return 0, 0, false, nil + } + s.maybeCleanup() + + now := time.Now() + mapKey := openAIWSResponseAccountMapKey(groupID, id) + s.responseOwnerMu.RLock() + if binding, ok := s.responseOwners[mapKey]; ok && now.Before(binding.expiresAt) { + s.responseOwnerMu.RUnlock() + return binding.userID, binding.apiKeyID, true, nil + } + s.responseOwnerMu.RUnlock() + + if s.cache == nil { + return 0, 0, false, nil + } + cacheCtx, cancel := withOpenAIWSStateStoreRedisTimeout(ctx) + defer cancel() + userID, err := s.cache.GetSessionAccountID(cacheCtx, groupID, openAIHTTPResponseOwnerCacheKey(openAIHTTPResponseOwnerUserPrefix, id)) + if err != nil || userID <= 0 { + return 0, 0, false, err + } + apiKeyID, err := s.cache.GetSessionAccountID(cacheCtx, groupID, openAIHTTPResponseOwnerCacheKey(openAIHTTPResponseOwnerKeyPrefix, id)) + if err != nil || apiKeyID <= 0 { + return 0, 0, false, err + } + + s.responseOwnerMu.Lock() + ensureBindingCapacity(s.responseOwners, mapKey, openAIWSStateStoreMaxEntriesPerMap) + s.responseOwners[mapKey] = openAIHTTPResponseOwnerBinding{ + userID: userID, apiKeyID: apiKeyID, expiresAt: now.Add(time.Minute), + } + s.responseOwnerMu.Unlock() + return userID, apiKeyID, true, nil +} + func (s *defaultOpenAIWSStateStore) BindResponseAccount(ctx context.Context, groupID int64, responseID string, accountID int64, ttl time.Duration) error { id := normalizeOpenAIWSResponseID(responseID) if id == "" || accountID <= 0 { @@ -115,6 +194,22 @@ func (s *defaultOpenAIWSStateStore) BindResponseAccount(ctx context.Context, gro return s.cache.SetSessionAccountID(cacheCtx, groupID, cacheKey, accountID, ttl) } +func cleanupExpiredHTTPResponseOwnerBindings(bindings map[string]openAIHTTPResponseOwnerBinding, now time.Time, maxScan int) { + if len(bindings) == 0 || maxScan <= 0 { + return + } + scanned := 0 + for key, binding := range bindings { + if now.After(binding.expiresAt) { + delete(bindings, key) + } + scanned++ + if scanned >= maxScan { + break + } + } +} + func (s *defaultOpenAIWSStateStore) GetResponseAccount(ctx context.Context, groupID int64, responseID string) (int64, error) { id := normalizeOpenAIWSResponseID(responseID) if id == "" { @@ -319,6 +414,10 @@ func (s *defaultOpenAIWSStateStore) maybeCleanup() { cleanupExpiredAccountBindings(s.responseToAccount, now, openAIWSStateStoreCleanupMaxPerMap) s.responseToAccountMu.Unlock() + s.responseOwnerMu.Lock() + cleanupExpiredHTTPResponseOwnerBindings(s.responseOwners, now, openAIWSStateStoreCleanupMaxPerMap) + s.responseOwnerMu.Unlock() + s.responseToConnMu.Lock() cleanupExpiredConnBindings(s.responseToConn, now, openAIWSStateStoreCleanupMaxPerMap) s.responseToConnMu.Unlock() @@ -419,6 +518,11 @@ func openAIWSResponseAccountCacheKey(responseID string) string { return openAIWSResponseAccountCachePrefix + hex.EncodeToString(sum[:]) } +func openAIHTTPResponseOwnerCacheKey(prefix, responseID string) string { + sum := sha256.Sum256([]byte(responseID)) + return prefix + hex.EncodeToString(sum[:]) +} + // openAIWSResponseAccountMapKey 本地热缓存按分组隔离的 key,与 Redis 层保持一致,避免跨组命中。 func openAIWSResponseAccountMapKey(groupID int64, responseID string) string { return fmt.Sprintf("%d:%s", groupID, responseID) diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index 9c98a1ea85..6b20a95507 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -28,6 +28,27 @@ func TestOpenAIWSStateStore_BindGetDeleteResponseAccount(t *testing.T) { require.Zero(t, accountID) } +func TestOpenAIWSStateStore_HTTPResponseOwnerPersistsAcrossStoreInstances(t *testing.T) { + cache := &stubGatewayCache{} + ctx := context.Background() + groupID := int64(8) + writer := NewOpenAIWSStateStore(cache) + + require.NoError(t, writer.BindHTTPResponseOwner(ctx, groupID, "resp_owned", 201, 301, time.Minute)) + userID, apiKeyID, found, err := writer.GetHTTPResponseOwner(ctx, groupID, "resp_owned") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, int64(201), userID) + require.Equal(t, int64(301), apiKeyID) + + reader := NewOpenAIWSStateStore(cache) + userID, apiKeyID, found, err = reader.GetHTTPResponseOwner(ctx, groupID, "resp_owned") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, int64(201), userID) + require.Equal(t, int64(301), apiKeyID) +} + func TestOpenAIWSStateStore_ResponseConnTTL(t *testing.T) { store := NewOpenAIWSStateStore(nil) store.BindResponseConn("resp_conn", "conn_1", 30*time.Millisecond) @@ -207,6 +228,13 @@ func (c *openAIWSStateStoreTimeoutProbeCache) ReleaseGrokVideoBilled(_ context.C return nil } +func (c *openAIWSStateStoreTimeoutProbeCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *openAIWSStateStoreTimeoutProbeCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) { probe := &openAIWSStateStoreTimeoutProbeCache{} store := NewOpenAIWSStateStore(probe) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 626faf891a..e28223e091 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -1,6 +1,7 @@ package openai_ws_v2 import ( + "bytes" "context" "errors" "io" @@ -13,6 +14,8 @@ import ( coderws "github.com/coder/websocket" "github.com/tidwall/gjson" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) type FrameConn interface { @@ -30,9 +33,12 @@ type Usage struct { } type RelayResult struct { - RequestModel string - ResponseModel string - ResponseModelConflict bool + RequestModel string + ResponseModel string + ResponseModelConflict bool + // ResponseServiceTier is the raw service_tier declared by the last terminal + // response event; "" when the upstream never declared one. + ResponseServiceTier string Usage Usage RequestID string TerminalEventType string @@ -47,9 +53,11 @@ type RelayTurnResult struct { RequestModel string ResponseModel string ResponseModelConflict bool + ResponseServiceTier string Usage Usage RequestID string TerminalEventType string + StartedAt time.Time Duration time.Duration FirstTokenMs *int } @@ -65,6 +73,8 @@ type RelayOptions struct { WriteTimeout time.Duration IdleTimeout time.Duration UpstreamDrainTimeout time.Duration + FirstTurnStartedAt time.Time + TakeNextTurnStartedAt func() time.Time FirstMessageType coderws.MessageType FirstMessageSent bool StartClientAfterFirstDownstream bool @@ -90,16 +100,20 @@ type RelayTraceEvent struct { } type relayState struct { - usage Usage - requestModelMu sync.RWMutex - requestModel string - lastResponseID string - lastResponseModel string - responseConflict bool - terminalEventType string - firstTokenMs *int - turnTimingByID map[string]*relayTurnTiming - activeTurn *relayTurnTiming + usage Usage + turnUsage Usage + requestModelMu sync.RWMutex + requestModel string + pendingTurnStart atomic.Pointer[time.Time] + lastResponseID string + lastResponseModel string + lastResponseServiceTier string + responseConflict bool + terminalEventType string + firstTokenMs *int + turnTimingByID map[string]*relayTurnTiming + activeTurn *relayTurnTiming + pendingBareError *observedUpstreamEvent } type relayExitSignal struct { @@ -110,14 +124,16 @@ type relayExitSignal struct { } type observedUpstreamEvent struct { - terminal bool - eventType string - responseID string - usage Usage - responseModel string - responseConflict bool - duration time.Duration - firstToken *int + terminal bool + eventType string + responseID string + usage Usage + startedAt time.Time + responseModel string + responseConflict bool + responseServiceTier string + duration time.Duration + firstToken *int } type relayTurnTiming struct { @@ -126,6 +142,9 @@ type relayTurnTiming struct { firstResponseModel string terminalResponseModel string responseModelConflict bool + // terminalResponseServiceTier is only taken from terminal events: earlier + // events echo the requested tier, not the one the upstream actually used. + terminalResponseServiceTier string } func Relay( @@ -161,6 +180,13 @@ func Relay( } startAt := nowFn() state := &relayState{requestModel: result.RequestModel} + if isClientResponseCreateFrame(firstMessageType, firstClientMessage) { + firstTurnStartedAt := options.FirstTurnStartedAt + if firstTurnStartedAt.IsZero() { + firstTurnStartedAt = startAt + } + state.setPendingTurnStartedAt(firstTurnStartedAt) + } onTrace := options.OnTrace relayCtx, relayCancel := context.WithCancel(ctx) @@ -178,8 +204,16 @@ func Relay( return upstreamConn.WriteFrame(writeCtx, msgType, payload) } writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { - if msgType == coderws.MessageText && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + if isClientResponseCreateFrame(msgType, payload) { state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) + turnStartedAt := time.Time{} + if options.TakeNextTurnStartedAt != nil { + turnStartedAt = options.TakeNextTurnStartedAt() + } + if turnStartedAt.IsZero() { + turnStartedAt = nowFn() + } + state.setPendingTurnStartedAt(turnStartedAt) } return writeUpstream(msgType, payload) } @@ -245,30 +279,34 @@ func Relay( if !options.StartClientAfterFirstDownstream { startClientReader() } - go runUpstreamToClient( - relayCtx, - upstreamConn, - writeClient, - startAt, - nowFn, - state, - options.OnUsageParseFailure, - options.OnTurnComplete, - options.BeforeWriteClient, - options.BeforeClientWrite, - options.AfterClientWrite, - func(msgType coderws.MessageType, payload []byte) { - if options.StartClientAfterFirstDownstream { - startClientReader() - } - }, - &dropDownstreamWrites, - upstreamToClientFrames, - droppedDownstreamFrames, - markActivity, - onTrace, - exitCh, - ) + upstreamDone := make(chan struct{}) + go func() { + defer close(upstreamDone) + runUpstreamToClient( + relayCtx, + upstreamConn, + writeClient, + startAt, + nowFn, + state, + options.OnUsageParseFailure, + options.OnTurnComplete, + options.BeforeWriteClient, + options.BeforeClientWrite, + options.AfterClientWrite, + func(msgType coderws.MessageType, payload []byte) { + if options.StartClientAfterFirstDownstream { + startClientReader() + } + }, + &dropDownstreamWrites, + upstreamToClientFrames, + droppedDownstreamFrames, + markActivity, + onTrace, + exitCh, + ) + }() go runIdleWatchdog(relayCtx, nowFn, options.IdleTimeout, &lastActivity, onTrace, exitCh) firstExit := <-exitCh @@ -322,7 +360,12 @@ func Relay( relayCancel() _ = upstreamConn.Close() + // ReadFrame observes relayCtx cancellation and Close is the transport-level + // fallback. Join the reader before touching relayState or firing the final + // turn callback; otherwise a late read can race Relay's result settlement. + <-upstreamDone + emitTurnComplete(options.OnTurnComplete, state, finalizePendingBareError(state, nowFn())) enrichResult(&result, state, nowFn().Sub(startAt)) result.ClientToUpstreamFrames = clientToUpstreamFrames.Load() result.UpstreamToClientFrames = upstreamToClientFrames.Load() @@ -412,6 +455,13 @@ func Relay( return result, nil } +func isClientResponseCreateFrame(msgType coderws.MessageType, payload []byte) bool { + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + return false + } + return strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" +} + func runClientToUpstream( ctx context.Context, clientConn FrameConn, @@ -482,6 +532,7 @@ func runUpstreamToClient( for { msgType, payload, err := upstreamConn.ReadFrame(ctx) if err != nil { + emitTurnComplete(onTurnComplete, state, finalizePendingBareError(state, nowFn())) emitRelayTrace(onTrace, RelayTraceEvent{ Stage: "read_upstream_failed", Direction: "upstream_to_client", @@ -519,6 +570,10 @@ func runUpstreamToClient( observedEvent := observedUpstreamEvent{} switch msgType { case coderws.MessageText: + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + if shouldFinalizePendingBareError(state, payload, eventType) { + emitTurnComplete(onTurnComplete, state, finalizePendingBareError(state, nowFn())) + } observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure) case coderws.MessageBinary: // binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。 @@ -711,22 +766,80 @@ func observeUpstreamMessage( if !isTerminalEvent(eventType) { return observed } - observed.terminal = true + observeRelayTurnResponseServiceTier(turnTiming, firstRelayResponseServiceTier(message)) state.terminalEventType = eventType + if eventType == "error" { + // Some Responses servers emit error immediately before response.failed. + // Defer turn settlement so the authoritative failed usage can replace + // this fallback instead of billing both terminal frames. + if observed.responseID == "" { + observed.responseID = openAIWSRelayActiveTurnID(state) + } + pending := observed + state.pendingBareError = &pending + return observed + } + state.pendingBareError = nil + return finalizeObservedRelayTerminal(state, observed, now) +} + +func shouldFinalizePendingBareError(state *relayState, payload []byte, eventType string) bool { + if state == nil || state.pendingBareError == nil { + return false + } + eventType = strings.TrimSpace(eventType) + if eventType == "" || eventType == "error" || eventType == "response.failed" { + return false + } + if isTerminalEvent(eventType) || eventType == "response.created" { + return true + } + // Auxiliary provider frames may be interleaved between error and its + // authoritative response.failed. Only a response event identifying a + // different turn closes the pending error. + responseID := strings.TrimSpace(gjson.GetBytes(payload, "response.id").String()) + if responseID == "" || state.pendingBareError.responseID == "" { + return false + } + return responseID != state.pendingBareError.responseID +} + +func finalizePendingBareError(state *relayState, now time.Time) observedUpstreamEvent { + if state == nil || state.pendingBareError == nil { + return observedUpstreamEvent{} + } + observed := *state.pendingBareError + state.pendingBareError = nil + return finalizeObservedRelayTerminal(state, observed, now) +} + +func finalizeObservedRelayTerminal(state *relayState, observed observedUpstreamEvent, now time.Time) observedUpstreamEvent { + if state == nil || strings.TrimSpace(observed.eventType) == "" { + return observedUpstreamEvent{} + } + observed.usage = finalizeRelayTurnUsage(state) + observed.terminal = true + responseID := strings.TrimSpace(observed.responseID) if responseID != "" { state.lastResponseID = responseID if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok { observed.responseModel = relayTurnResponseModel(&turnTiming) observed.responseConflict = turnTiming.responseModelConflict + observed.responseServiceTier = turnTiming.terminalResponseServiceTier state.lastResponseModel = observed.responseModel state.responseConflict = observed.responseConflict + state.lastResponseServiceTier = observed.responseServiceTier duration := now.Sub(turnTiming.startAt) if duration < 0 { duration = 0 } + observed.startedAt = turnTiming.startAt observed.duration = duration observed.firstToken = openAIWSRelayCloneIntPtr(turnTiming.firstTokenMs) } + } else { + state.consumePendingTurnStartedAt() + openAIWSRelayDiscardActiveTurnTiming(state) } return observed } @@ -740,7 +853,7 @@ func emitTurnComplete( return } responseID := strings.TrimSpace(observed.responseID) - if responseID == "" { + if responseID == "" && strings.TrimSpace(observed.eventType) != "error" { return } requestModel := "" @@ -751,9 +864,11 @@ func emitTurnComplete( RequestModel: requestModel, ResponseModel: observed.responseModel, ResponseModelConflict: observed.responseConflict, + ResponseServiceTier: observed.responseServiceTier, Usage: observed.usage, RequestID: responseID, TerminalEventType: observed.eventType, + StartedAt: observed.startedAt, Duration: observed.duration, FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken), }) @@ -806,6 +921,31 @@ func relayTurnResponseModel(turn *relayTurnTiming) string { return turn.firstResponseModel } +func firstRelayResponseServiceTier(message []byte) string { + if len(message) == 0 { + return "" + } + values := gjson.GetManyBytes(message, "response.service_tier", "service_tier") + for _, value := range values { + if value.Type != gjson.String { + continue + } + if tier := strings.TrimSpace(value.String()); tier != "" { + return tier + } + } + return "" +} + +func observeRelayTurnResponseServiceTier(turn *relayTurnTiming, tier string) { + if turn == nil { + return + } + if tier = strings.TrimSpace(tier); tier != "" { + turn.terminalResponseServiceTier = tier + } +} + func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now time.Time) *relayTurnTiming { if state == nil { return nil @@ -815,7 +955,11 @@ func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now } timing, ok := state.turnTimingByID[responseID] if !ok || timing == nil || timing.startAt.IsZero() { - timing = &relayTurnTiming{startAt: now} + startAt := state.consumePendingTurnStartedAt() + if startAt.IsZero() { + startAt = now + } + timing = &relayTurnTiming{startAt: startAt} state.turnTimingByID[responseID] = timing state.activeTurn = timing return timing @@ -823,6 +967,25 @@ func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now return timing } +func (s *relayState) setPendingTurnStartedAt(startedAt time.Time) { + if s == nil || startedAt.IsZero() { + return + } + startedAtCopy := startedAt + s.pendingTurnStart.Store(&startedAtCopy) +} + +func (s *relayState) consumePendingTurnStartedAt() time.Time { + if s == nil { + return time.Time{} + } + startedAt := s.pendingTurnStart.Swap(nil) + if startedAt == nil { + return time.Time{} + } + return *startedAt +} + func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) { if state == nil || state.turnTimingByID == nil { return relayTurnTiming{}, false @@ -838,6 +1001,31 @@ func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayT return *timing, true } +func openAIWSRelayDiscardActiveTurnTiming(state *relayState) { + if state == nil || state.activeTurn == nil { + return + } + active := state.activeTurn + for responseID, timing := range state.turnTimingByID { + if timing == active { + delete(state.turnTimingByID, responseID) + } + } + state.activeTurn = nil +} + +func openAIWSRelayActiveTurnID(state *relayState) string { + if state == nil || state.activeTurn == nil { + return "" + } + for responseID, timing := range state.turnTimingByID { + if timing == state.activeTurn { + return responseID + } + } + return "" +} + func openAIWSRelayCloneIntPtr(v *int) *int { if v == nil { return nil @@ -852,10 +1040,13 @@ func parseUsageAndAccumulate( eventType string, onParseFailure func(eventType string, usageRaw string), ) Usage { - if state == nil || len(message) == 0 || !shouldParseUsage(eventType) { + if state == nil || len(message) == 0 || !shouldParseUsage(eventType) || !bytes.Contains(message, []byte(`"usage"`)) { return Usage{} } usageResult := gjson.GetBytes(message, "response.usage") + if !usageResult.Exists() { + usageResult = gjson.GetBytes(message, "usage") + } if !usageResult.Exists() { return Usage{} } @@ -868,25 +1059,26 @@ func parseUsageAndAccumulate( return Usage{} } - inputResult := gjson.GetBytes(message, "response.usage.input_tokens") + inputResult := usageResult.Get("input_tokens") if !inputResult.Exists() { - inputResult = gjson.GetBytes(message, "response.usage.prompt_tokens") + inputResult = usageResult.Get("prompt_tokens") } - outputResult := gjson.GetBytes(message, "response.usage.output_tokens") + outputResult := usageResult.Get("output_tokens") if !outputResult.Exists() { - outputResult = gjson.GetBytes(message, "response.usage.completion_tokens") + outputResult = usageResult.Get("completion_tokens") } - cachedResult := gjson.GetBytes(message, "response.usage.input_tokens_details.cached_tokens") + cachedResult := usageResult.Get("input_tokens_details.cached_tokens") if !cachedResult.Exists() { - cachedResult = gjson.GetBytes(message, "response.usage.prompt_tokens_details.cached_tokens") + cachedResult = usageResult.Get("prompt_tokens_details.cached_tokens") } imageTokens := usageResult.Get("output_tokens_details.image_tokens").Int() if imageTokens == 0 { imageTokens = usageResult.Get("completion_tokens_details.image_tokens").Int() } - inputTokens, inputOK := parseUsageIntField(inputResult, true) - outputTokens, outputOK := parseUsageIntField(outputResult, true) + requireTotals := isTerminalEvent(strings.TrimSpace(eventType)) + inputTokens, inputOK := parseUsageIntField(inputResult, requireTotals) + outputTokens, outputOK := parseUsageIntField(outputResult, requireTotals) cachedTokens, cachedOK := parseUsageIntField(cachedResult, false) if !inputOK || !outputOK || !cachedOK { recordUsageParseFailure() @@ -896,6 +1088,15 @@ func parseUsageAndAccumulate( // 解析失败时不做部分字段累加,避免计费 usage 出现“半有效”状态。 return Usage{} } + reasoningTokens := usageResult.Get("output_tokens_details.reasoning_tokens").Int() + if reasoningTokens == 0 { + reasoningTokens = usageResult.Get("completion_tokens_details.reasoning_tokens").Int() + } + if reasoningTokens > 0 { + outputTokens = int(xai.IncludeIndependentReasoningTokens( + int64(inputTokens), int64(outputTokens), usageResult.Get("total_tokens").Int(), reasoningTokens, + )) + } parsedUsage := Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, @@ -904,14 +1105,58 @@ func parseUsageAndAccumulate( ImageOutputTokens: int(imageTokens), } - state.usage.InputTokens += parsedUsage.InputTokens - state.usage.OutputTokens += parsedUsage.OutputTokens - state.usage.CacheCreationInputTokens += parsedUsage.CacheCreationInputTokens - state.usage.CacheReadInputTokens += parsedUsage.CacheReadInputTokens - state.usage.ImageOutputTokens += parsedUsage.ImageOutputTokens + if isTerminalEvent(strings.TrimSpace(eventType)) { + if relayUsageHasTokens(parsedUsage) || !relayUsageHasTokens(state.turnUsage) { + state.turnUsage = parsedUsage + } + } else { + mergeRelayUsageNonZero(&state.turnUsage, parsedUsage) + return Usage{} + } return parsedUsage } +func relayUsageHasTokens(usage Usage) bool { + return usage.InputTokens > 0 || usage.OutputTokens > 0 || + usage.CacheCreationInputTokens > 0 || usage.CacheReadInputTokens > 0 || + usage.ImageOutputTokens > 0 +} + +func mergeRelayUsageNonZero(dst *Usage, src Usage) { + if dst == nil { + return + } + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.OutputTokens > 0 { + dst.OutputTokens = src.OutputTokens + } + if src.CacheCreationInputTokens > 0 { + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } + if src.CacheReadInputTokens > 0 { + dst.CacheReadInputTokens = src.CacheReadInputTokens + } + if src.ImageOutputTokens > 0 { + dst.ImageOutputTokens = src.ImageOutputTokens + } +} + +func finalizeRelayTurnUsage(state *relayState) Usage { + if state == nil { + return Usage{} + } + turnUsage := state.turnUsage + state.usage.InputTokens += turnUsage.InputTokens + state.usage.OutputTokens += turnUsage.OutputTokens + state.usage.CacheCreationInputTokens += turnUsage.CacheCreationInputTokens + state.usage.CacheReadInputTokens += turnUsage.CacheReadInputTokens + state.usage.ImageOutputTokens += turnUsage.ImageOutputTokens + state.turnUsage = Usage{} + return turnUsage +} + func parseUsageIntField(value gjson.Result, required bool) (int, bool) { if !value.Exists() { return 0, !required @@ -958,6 +1203,7 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration result.RequestModel = state.currentRequestModel() result.ResponseModel = state.lastResponseModel result.ResponseModelConflict = state.responseConflict + result.ResponseServiceTier = state.lastResponseServiceTier result.Usage = state.usage result.RequestID = state.lastResponseID result.TerminalEventType = state.terminalEventType @@ -1005,8 +1251,8 @@ func isDisconnectError(err error) bool { } func isTerminalEvent(eventType string) bool { - switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + switch strings.TrimSpace(eventType) { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error": return true default: return false @@ -1014,12 +1260,11 @@ func isTerminalEvent(eventType string) bool { } func shouldParseUsage(eventType string) bool { - switch eventType { - case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + eventType = strings.TrimSpace(eventType) + if eventType == "error" || isTerminalEvent(eventType) { return true - default: - return false } + return strings.HasPrefix(eventType, "response.") && !strings.HasSuffix(eventType, ".delta") } func isTokenEvent(eventType string) bool { diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index ebb2394a12..80fef47d44 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -307,6 +307,7 @@ func TestParseUsageAndEnrichCoverage(t *testing.T) { require.Equal(t, 0, state.usage.CacheReadInputTokens) parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":2,"output_tokens":1,"input_tokens_details":{"cached_tokens":1,"cache_write_tokens":4},"output_tokens_details":{"image_tokens":3}}}}`), "response.completed", nil) + finalizeRelayTurnUsage(state) require.Equal(t, 2, state.usage.InputTokens) require.Equal(t, 1, state.usage.OutputTokens) require.Equal(t, 1, state.usage.CacheReadInputTokens) @@ -324,6 +325,29 @@ func TestParseUsageAndEnrichCoverage(t *testing.T) { enrichResult(nil, state, 0) } +func TestParseUsageAndAccumulateIncludesIndependentReasoningTokens(t *testing.T) { + t.Parallel() + + state := &relayState{} + got := parseUsageAndAccumulate( + state, + []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":32,"output_tokens":9,"total_tokens":151,"output_tokens_details":{"reasoning_tokens":110}}}}`), + "response.completed", + nil, + ) + require.Equal(t, 32, got.InputTokens) + require.Equal(t, 119, got.OutputTokens) + + state = &relayState{} + got = parseUsageAndAccumulate( + state, + []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":32,"output_tokens":119,"total_tokens":151,"output_tokens_details":{"reasoning_tokens":110}}}}`), + "response.completed", + nil, + ) + require.Equal(t, 119, got.OutputTokens, "inclusive Responses output must not double-count reasoning") +} + func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) { t.Parallel() @@ -334,6 +358,7 @@ func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) { "response.done", nil, ) + finalizeRelayTurnUsage(state) require.Equal(t, 12, got.InputTokens) require.Equal(t, 6, got.OutputTokens) require.Equal(t, 4, got.CacheReadInputTokens) @@ -341,6 +366,141 @@ func TestParseUsageAndAccumulateAcceptsChatUsageAliases(t *testing.T) { require.Equal(t, got, state.usage) } +func TestRelayUsageTerminalWithoutUsageKeepsFallback(t *testing.T) { + t.Parallel() + + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","response":{"id":"resp_1"}}`), "response.completed", nil) + + turnUsage := finalizeRelayTurnUsage(state) + require.Equal(t, Usage{InputTokens: 9, OutputTokens: 4}, turnUsage) + require.Equal(t, turnUsage, state.usage) +} + +func TestRelayUsageTerminalZeroKeepsFallback(t *testing.T) { + t.Parallel() + + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","usage":{"input_tokens":0,"output_tokens":0}}`), "response.completed", nil) + + require.Equal(t, Usage{InputTokens: 9, OutputTokens: 4}, finalizeRelayTurnUsage(state)) + require.Equal(t, Usage{InputTokens: 9, OutputTokens: 4}, state.usage) +} + +func TestRelayUsageTerminalNonZeroReplacesFallbackAsWhole(t *testing.T) { + t.Parallel() + + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}`), "response.in_progress", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","usage":{"input_tokens":3,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}`), "response.completed", nil) + + require.Equal(t, Usage{InputTokens: 3}, finalizeRelayTurnUsage(state)) + require.Equal(t, Usage{InputTokens: 3}, state.usage) +} + +func TestObserveUpstreamMessageBareErrorClearsTurnStateAndFinalizesUsageOnce(t *testing.T) { + t.Parallel() + + now := time.Unix(200, 0) + state := &relayState{} + state.setPendingTurnStartedAt(now.Add(-2 * time.Second)) + timing := openAIWSRelayGetOrInitTurnTiming(state, "resp_active", now.Add(-time.Second)) + require.NotNil(t, timing) + state.setPendingTurnStartedAt(now.Add(-500 * time.Millisecond)) + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + + observed := observeUpstreamMessage( + state, + []byte(`{"type":"error","usage":{"input_tokens":3,"output_tokens":1},"error":{"message":"failed"}}`), + now.Add(-3*time.Second), + func() time.Time { return now }, + nil, + ) + + require.False(t, observed.terminal, "bare error settlement is deferred in case response.failed follows") + require.Equal(t, "error", observed.eventType) + observed = finalizePendingBareError(state, now) + require.True(t, observed.terminal) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 1}, observed.usage) + require.Equal(t, observed.usage, state.usage) + require.Equal(t, Usage{}, state.turnUsage) + require.Nil(t, state.activeTurn) + require.Empty(t, state.turnTimingByID) + // The error is bound to the active turn id, so settlement must not consume a + // later pending start that belongs to the next turn. + require.Equal(t, now.Add(-500*time.Millisecond), state.consumePendingTurnStartedAt()) + + // Re-observing and settling a later terminal without usage must not re-add the prior turn. + observeUpstreamMessage(state, []byte(`{"type":"error","error":{"message":"again"}}`), now, func() time.Time { return now }, nil) + finalizePendingBareError(state, now) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 1}, state.usage) +} + +func TestObserveUpstreamMessageErrorThenFailedSettlesUsageOnce(t *testing.T) { + t.Parallel() + + now := time.Unix(300, 0) + state := &relayState{} + state.setPendingTurnStartedAt(now.Add(-time.Second)) + openAIWSRelayGetOrInitTurnTiming(state, "resp_1", now) + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":9,"output_tokens":4}}`), "response.in_progress", nil) + + errorObserved := observeUpstreamMessage( + state, + []byte(`{"type":"error","error":{"code":"server_is_overloaded","message":"overloaded"}}`), + now, + func() time.Time { return now }, + nil, + ) + require.False(t, errorObserved.terminal) + require.NotNil(t, state.pendingBareError) + require.Equal(t, Usage{}, state.usage) + + failedObserved := observeUpstreamMessage( + state, + []byte(`{"type":"response.failed","response":{"id":"resp_1","usage":{"input_tokens":7,"output_tokens":2}}}`), + now, + func() time.Time { return now }, + nil, + ) + require.True(t, failedObserved.terminal) + require.Equal(t, Usage{InputTokens: 7, OutputTokens: 2}, failedObserved.usage) + require.Equal(t, failedObserved.usage, state.usage) + require.Nil(t, state.pendingBareError) +} + +func TestObserveUpstreamMessageBareErrorBeforeNextCompletedKeepsBothTurns(t *testing.T) { + t.Parallel() + + now := time.Unix(400, 0) + state := &relayState{} + parseUsageAndAccumulate(state, []byte(`{"type":"response.in_progress","usage":{"input_tokens":5,"output_tokens":1}}`), "response.in_progress", nil) + bare := observeUpstreamMessage( + state, + []byte(`{"type":"error","error":{"message":"first turn failed"}}`), + now, + func() time.Time { return now }, + nil, + ) + require.False(t, bare.terminal) + bare = finalizePendingBareError(state, now) + require.True(t, bare.terminal) + require.Equal(t, Usage{InputTokens: 5, OutputTokens: 1}, bare.usage) + + completed := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_next","usage":{"input_tokens":3,"output_tokens":2}}}`), + now, + func() time.Time { return now }, + nil, + ) + require.True(t, completed.terminal) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 2}, completed.usage) + require.Equal(t, Usage{InputTokens: 8, OutputTokens: 3}, state.usage) +} + func TestOpenAICacheCreationTokensFromUsageNestedZeroWins(t *testing.T) { t.Parallel() @@ -363,7 +523,7 @@ func TestEmitTurnCompleteCoverage(t *testing.T) { }) require.Equal(t, 0, called) - // 缺少 response_id 时不应触发。 + // 非 error 终态缺少 response_id 时不应触发。 emitTurnComplete(func(turn RelayTurnResult) { called++ }, &relayState{requestModel: "gpt-5"}, observedUpstreamEvent{ @@ -372,6 +532,22 @@ func TestEmitTurnCompleteCoverage(t *testing.T) { }) require.Equal(t, 0, called) + // Bare error legitimately has no response_id; it must still settle the turn + // or a later completed turn would suppress the adapter's aggregate fallback. + var bareError RelayTurnResult + emitTurnComplete(func(turn RelayTurnResult) { + called++ + bareError = turn + }, &relayState{requestModel: "gpt-5"}, observedUpstreamEvent{ + terminal: true, + eventType: "error", + usage: Usage{InputTokens: 4, OutputTokens: 1}, + }) + require.Equal(t, 1, called) + require.Empty(t, bareError.RequestID) + require.Equal(t, "error", bareError.TerminalEventType) + require.Equal(t, Usage{InputTokens: 4, OutputTokens: 1}, bareError.Usage) + // terminal 且 response_id 存在,应该触发;state=nil 时 model 为空串。 var got RelayTurnResult emitTurnComplete(func(turn RelayTurnResult) { @@ -383,7 +559,7 @@ func TestEmitTurnCompleteCoverage(t *testing.T) { responseID: "resp_emit", usage: Usage{InputTokens: 2, OutputTokens: 3}, }) - require.Equal(t, 1, called) + require.Equal(t, 2, called) require.Equal(t, "resp_emit", got.RequestID) require.Equal(t, "response.completed", got.TerminalEventType) require.Equal(t, 2, got.Usage.InputTokens) @@ -568,3 +744,54 @@ func TestObserveUpstreamMessage_ResponseIDFallbackPolicy(t *testing.T) { require.True(t, observed.terminal) require.Equal(t, "resp_fallback", observed.responseID) } + +func TestObserveUpstreamMessage_ResponseServiceTierOnlyFromTerminalEvents(t *testing.T) { + t.Parallel() + + state := &relayState{requestModel: "gpt-5.6-sol"} + startAt := time.Unix(0, 0) + now := startAt + nowFn := func() time.Time { + now = now.Add(5 * time.Millisecond) + return now + } + + created := observeUpstreamMessage( + state, + []byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"priority"}}`), + startAt, + nowFn, + nil, + ) + require.False(t, created.terminal) + require.Equal(t, "", created.responseServiceTier) + + completed := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default","usage":{"input_tokens":1,"output_tokens":2}}}`), + startAt, + nowFn, + nil, + ) + require.True(t, completed.terminal) + require.Equal(t, "default", completed.responseServiceTier, "the echoed priority tier must lose to the terminal declaration") + + var turn RelayTurnResult + emitTurnComplete(func(result RelayTurnResult) { turn = result }, state, completed) + require.Equal(t, "default", turn.ResponseServiceTier) + + var result RelayResult + enrichResult(&result, state, now.Sub(startAt)) + require.Equal(t, "default", result.ResponseServiceTier) + + // A later turn without any declaration must not inherit the previous one. + observeUpstreamMessage(state, []byte(`{"type":"response.created","response":{"id":"resp_2","model":"gpt-5.6-sol"}}`), startAt, nowFn, nil) + second := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_2","model":"gpt-5.6-sol","usage":{"input_tokens":3,"output_tokens":4}}}`), + startAt, + nowFn, + nil, + ) + require.Equal(t, "", second.responseServiceTier) +} diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index c41e7d293b..28b896f248 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -41,6 +41,16 @@ type closeSpyFrameConn struct { closeCalls atomic.Int32 } +type cancelJoinProbeFrameConn struct { + readStarted chan struct{} + readCanceled chan struct{} + allowReturn chan struct{} + readReturned chan struct{} + startOnce sync.Once + cancelOnce sync.Once + returnOnce sync.Once +} + func newPassthroughTestFrameConn(frames []passthroughTestFrame, autoClose bool) *passthroughTestFrameConn { c := &passthroughTestFrameConn{ readCh: make(chan passthroughTestFrame, len(frames)+1), @@ -179,6 +189,38 @@ func (c *closeSpyFrameConn) CloseCalls() int32 { return c.closeCalls.Load() } +func newCancelJoinProbeFrameConn() *cancelJoinProbeFrameConn { + return &cancelJoinProbeFrameConn{ + readStarted: make(chan struct{}), + readCanceled: make(chan struct{}), + allowReturn: make(chan struct{}), + readReturned: make(chan struct{}), + } +} + +func (c *cancelJoinProbeFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) { + c.startOnce.Do(func() { close(c.readStarted) }) + <-ctx.Done() + c.cancelOnce.Do(func() { close(c.readCanceled) }) + <-c.allowReturn + c.returnOnce.Do(func() { close(c.readReturned) }) + return coderws.MessageText, nil, ctx.Err() +} + +func (c *cancelJoinProbeFrameConn) WriteFrame(ctx context.Context, _ coderws.MessageType, _ []byte) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func (c *cancelJoinProbeFrameConn) Close() error { return nil } + func TestRelay_BasicRelayAndUsage(t *testing.T) { t.Parallel() @@ -372,6 +414,58 @@ func TestRelay_IdleTimeoutDoesNotCloseClientOnError(t *testing.T) { require.GreaterOrEqual(t, upstreamConn.CloseCalls(), int32(1)) } +func TestRelay_JoinsUpstreamReaderBeforeReturning(t *testing.T) { + t.Parallel() + + clientConn := &closeSpyFrameConn{} + upstreamConn := newCancelJoinProbeFrameConn() + ctx, cancel := context.WithCancel(context.Background()) + resultCh := make(chan *RelayExit, 1) + + go func() { + _, relayExit := Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`), + RelayOptions{}, + ) + resultCh <- relayExit + }() + + select { + case <-upstreamConn.readStarted: + case <-time.After(time.Second): + t.Fatal("upstream reader did not start") + } + cancel() + select { + case <-upstreamConn.readCanceled: + case <-time.After(time.Second): + t.Fatal("upstream reader did not observe relay cancellation") + } + select { + case <-resultCh: + t.Fatal("Relay returned before the upstream reader exited") + case <-time.After(50 * time.Millisecond): + } + + close(upstreamConn.allowReturn) + select { + case relayExit := <-resultCh: + require.NotNil(t, relayExit) + require.ErrorIs(t, relayExit.Err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("Relay did not return after the upstream reader exited") + } + select { + case <-upstreamConn.readReturned: + default: + t.Fatal("Relay returned before the upstream reader completion signal") + } + require.Zero(t, clientConn.CloseCalls(), "错误路径不应提前关闭客户端连接") +} + func TestRelay_NilConnections(t *testing.T) { t.Parallel() @@ -472,6 +566,68 @@ func TestRelay_OnTurnComplete_PerTerminalEvent(t *testing.T) { require.Equal(t, 5, result.Usage.OutputTokens) } +func TestRelay_OnTurnComplete_BareErrorWithoutIDBeforeLaterCompleted(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"error","usage":{"input_tokens":5,"output_tokens":1},"error":{"message":"first turn failed"}}`), + }, + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_next","usage":{"input_tokens":3,"output_tokens":2}}}`), + }, + }, true) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + turns := make([]RelayTurnResult, 0, 2) + result, relayExit := Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`), + RelayOptions{OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) }}, + ) + + require.Nil(t, relayExit) + require.Len(t, turns, 2) + require.Equal(t, "error", turns[0].TerminalEventType) + require.Empty(t, turns[0].RequestID) + require.Equal(t, Usage{InputTokens: 5, OutputTokens: 1}, turns[0].Usage) + require.Equal(t, "response.completed", turns[1].TerminalEventType) + require.Equal(t, "resp_next", turns[1].RequestID) + require.Equal(t, Usage{InputTokens: 3, OutputTokens: 2}, turns[1].Usage) + require.Equal(t, Usage{InputTokens: 8, OutputTokens: 3}, result.Usage) +} + +func TestRelay_OnTurnComplete_AuxiliaryFrameDoesNotSettleBareErrorBeforeFailed(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + {msgType: coderws.MessageText, payload: []byte(`{"type":"error","error":{"code":"server_error","message":"failed"}}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"rate_limits.updated","rate_limits":[{"name":"requests","remaining":1}]}`)}, + {msgType: coderws.MessageText, payload: []byte(`{"type":"response.failed","response":{"id":"resp_authoritative","usage":{"input_tokens":7,"output_tokens":2},"error":{"code":"server_error","message":"failed"}}}`)}, + }, true) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + turns := make([]RelayTurnResult, 0, 1) + result, relayExit := Relay(ctx, clientConn, upstreamConn, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`), RelayOptions{ + OnTurnComplete: func(turn RelayTurnResult) { turns = append(turns, turn) }, + }) + + require.Nil(t, relayExit) + require.Len(t, turns, 1) + require.Equal(t, "response.failed", turns[0].TerminalEventType) + require.Equal(t, "resp_authoritative", turns[0].RequestID) + require.Equal(t, Usage{InputTokens: 7, OutputTokens: 2}, turns[0].Usage) + require.Equal(t, turns[0].Usage, result.Usage) +} + func TestRelay_OnTurnComplete_UsesCurrentResponseCreateModel(t *testing.T) { t.Parallel() @@ -565,6 +721,143 @@ func TestRelay_OnTurnComplete_ProvidesTurnMetrics(t *testing.T) { require.Greater(t, result.Duration.Milliseconds(), int64(0)) } +func TestRelay_OnTurnComplete_UsesResponseCreateTimeAcrossPricingBoundary(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + }, true) + + responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + upstreamResponseAt := responseCreateAt.Add(time.Second) + var nowCalls atomic.Int64 + nowFn := func() time.Time { + if nowCalls.Add(1) == 1 { + return responseCreateAt + } + return upstreamResponseAt + } + + var turn RelayTurnResult + _, relayExit := Relay( + context.Background(), + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{ + Now: nowFn, + OnTurnComplete: func(current RelayTurnResult) { turn = current }, + }, + ) + + require.Nil(t, relayExit) + require.Equal(t, responseCreateAt, turn.StartedAt) +} + +func TestRelay_OnTurnComplete_UsesExplicitFirstTurnStartedAt(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_initial_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + }, true) + + responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + relayStartedAt := responseCreateAt.Add(time.Second) + var turn RelayTurnResult + _, relayExit := Relay( + context.Background(), + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{ + FirstTurnStartedAt: responseCreateAt, + Now: func() time.Time { return relayStartedAt }, + OnTurnComplete: func(current RelayTurnResult) { turn = current }, + }, + ) + + require.Nil(t, relayExit) + require.Equal(t, responseCreateAt, turn.StartedAt) +} + +func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBoundary(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + firstTurnAt := time.Date(2026, time.August, 17, 9, 0, 0, 0, time.UTC) + secondTurnAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + secondResponseAt := secondTurnAt.Add(time.Second) + var clock atomic.Int64 + clock.Store(firstTurnAt.UnixNano()) + + turns := make(chan RelayTurnResult, 2) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{ + Now: func() time.Time { return time.Unix(0, clock.Load()).UTC() }, + OnTurnComplete: func(current RelayTurnResult) { + turns <- current + }, + }, + ) + }() + + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + select { + case <-turns: + case <-time.After(time.Second): + t.Fatal("first turn did not complete") + } + + clock.Store(secondTurnAt.UnixNano()) + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + } + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond) + clock.Store(secondResponseAt.UnixNano()) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_second","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + + var secondTurn RelayTurnResult + select { + case secondTurn = <-turns: + case <-time.After(time.Second): + t.Fatal("second turn did not complete") + } + require.Equal(t, secondTurnAt, secondTurn.StartedAt) + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("relay did not stop after cancellation") + } +} + func TestRelay_BinaryFramePassthrough(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 775e26980c..60e7b41416 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -28,6 +28,7 @@ type openAIWSClientFrameConn struct { // The relay observes upstream payloads, while clients must keep seeing the // model identifier they supplied for the current turn. restoreResponseModel func([]byte) []byte + restoreToolNames func([]byte) []byte } // openAIWSPolicyEnforcingFrameConn wraps a client-side FrameConn and runs @@ -85,11 +86,9 @@ func (c *openAIWSPolicyEnforcingFrameConn) Close() error { return c.inner.Close() } -// openAIWSPassthroughPolicyModelForFrame returns the upstream-perspective -// model name that should be passed to evaluateOpenAIFastPolicy for a single -// passthrough WS frame. Mirrors the HTTP-side normalization -// (account.GetMappedModel + normalizeOpenAIModelForUpstream) so the WS path -// matches model whitelists identically. +// openAIWSPassthroughPolicyModelForFrame returns the model actually forwarded +// by a passthrough WS frame. Passthrough only replaces authentication, so an +// account's ordinary model_mapping and Codex aliases must not rewrite it. func openAIWSPassthroughPolicyModelForFrame(account *Account, payload []byte) string { if account == nil || len(payload) == 0 { return "" @@ -98,7 +97,7 @@ func openAIWSPassthroughPolicyModelForFrame(account *Account, payload []byte) st if original == "" { return "" } - return normalizeOpenAIModelForUpstream(account, account.GetMappedModel(original)) + return original } // openAIWSPassthroughPolicyModelFromSessionFrame returns the upstream model @@ -130,7 +129,7 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload [] if original == "" { return "" } - return normalizeOpenAIModelForUpstream(account, account.GetMappedModel(original)) + return original } type openAIWSPassthroughUsageMeta struct { @@ -639,6 +638,9 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw if c.restoreResponseModel != nil { payload = c.restoreResponseModel(payload) } + if c.restoreToolNames != nil { + payload = c.restoreToolNames(payload) + } } return c.conn.Write(ctx, msgType, payload) } @@ -662,6 +664,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( hooks *OpenAIWSIngressHooks, wsDecision OpenAIWSProtocolDecision, ) error { + // The same request context survives scheduler failover attempts. Clear any + // reverse mapping installed by the prior account before processing frames. + setCodexToolNameReverse(c, nil) if s == nil { return errors.New("service is nil") } @@ -674,8 +679,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } - if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { - liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage) + if isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { + liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(firstClientMessage, account) if liteErr != nil { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } @@ -688,6 +693,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String()) + promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) logOpenAIWSV2Passthrough( "relay_start account_id=%d model=%s previous_response_id=%s first_message_type=%s first_message_bytes=%d", account.ID, @@ -729,6 +735,29 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } + firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { + return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) + } else if compatibilityChanged { + firstClientMessage = normalized + } + if account.IsOpenAIOAuthLike() { + aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(firstClientMessage) + if aliasErr != nil { + return aliasErr + } + updateCodexToolNameReverseForWSFrame(c, firstClientMessage, reverse) + if aliased { + firstClientMessage = aliasedBody + } + } + accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + firstClientMessage = accountScopedFirst + } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { @@ -767,8 +796,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // goroutine)和 OnTurnComplete / final result(runUpstreamToClient // goroutine)之间同步当前 turn 的 usage metadata。 usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel) - promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) - + _, initialUpstreamModel := usageMeta.turnModels(initialRequestModel) + SetOpsUpstreamModel(c, initialUpstreamModel) wsURL, err := s.buildOpenAIResponsesWSURL(account) if err != nil { return fmt.Errorf("build ws url: %w", err) @@ -863,10 +892,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( s.handleOpenAIWSDialTransientFailure(ctx, account, capturedSessionModel, dialErr) if statusCode == http.StatusTooManyRequests { s.persistOpenAIWSRateLimitSignal(ctx, account, handshakeHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error())) - return &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseHeaders: cloneHeader(handshakeHeaders), - } + return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, nil, err.Error()) } return s.mapOpenAIWSPassthroughDialError(err, statusCode, handshakeHeaders) } @@ -915,6 +941,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( completedTurns := atomic.Int32{} turnLifecycle := newOpenAIWSPassthroughTurnLifecycle(true) + var acceptedTurnStartedAt atomic.Pointer[time.Time] clientFrameConn := &openAIWSClientFrameConn{ conn: clientConn, controlCtx: ctx, @@ -928,6 +955,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( requestModel, upstreamModel := usageMeta.turnModels("") return replaceOpenAIWSMessageModel(payload, upstreamModel, requestModel) }, + restoreToolNames: func(payload []byte) []byte { + return restoreCodexToolNamesFromContext(c, payload) + }, } policyClientConn := &openAIWSPolicyEnforcingFrameConn{ inner: clientFrameConn, @@ -936,13 +966,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // capturedSessionModel 的读写都发生在该 goroutine 内,因此无需 // 加锁/原子化。 filter: func(msgType coderws.MessageType, payload []byte) (out []byte, blocked *OpenAIFastBlockedError, filterErr error) { - if msgType != coderws.MessageText { + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { return payload, nil, nil } eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) isResponseCreate := eventType == "response.create" + responseCreateAt := time.Time{} acceptedTurn := false if isResponseCreate { + responseCreateAt = time.Now() if !turnLifecycle.beginResponseCreate(clientFrameConn.markTurnStarted) { err := errors.New("overlapping response.create is not supported") return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) @@ -953,9 +985,36 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } }() } + responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload) if isResponseCreate { - if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(payload) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) + } else if compatibilityChanged { + payload = normalized + } + } + if account.IsOpenAIOAuthLike() && (isResponseCreate || eventType == "session.update") { + aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(payload) + if aliasErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr) + } + updateCodexToolNameReverseForWSFrame(c, payload, reverse) + if aliased { + payload = aliasedBody + } + } + if isResponseCreate || eventType == "session.update" { + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + payload = accountScopedPayload + } + } + if isResponseCreate { + if responsesLite { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account) if liteErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } @@ -1035,6 +1094,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // service_tier 时按 default 处理,billing 应如实反映。 if policyErr == nil && blocked == nil && isResponseCreate { usageMeta.updateFromResponseCreate(out, model, requestModelForThisFrame) + _, actualModel := usageMeta.turnModels(requestModelForThisFrame) + SetOpsUpstreamModel(c, actualModel) + responseCreateAtCopy := responseCreateAt + acceptedTurnStartedAt.Store(&responseCreateAtCopy) acceptedTurn = true } return out, blocked, policyErr @@ -1072,7 +1135,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if readErr != nil { return msgType, payload, readErr } - if msgType == coderws.MessageText && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + if (msgType == coderws.MessageText || msgType == coderws.MessageBinary) && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { return msgType, payload, nil } if writeErr := upstreamFrameConn.WriteFrame(readCtx, msgType, payload); writeErr != nil { @@ -1081,13 +1144,26 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } + firstTurnStartedAt := time.Time{} + if hooks != nil { + firstTurnStartedAt = hooks.InitialTurnStartedAt + } + failureAccountSideEffectsApplied := false relayResult, relayExit := openaiwsv2.RunEntry(openaiwsv2.EntryInput{ Ctx: ctx, ClientConn: policyClientConn, UpstreamConn: relayUpstreamFrameConn, FirstClientMessage: firstClientMessage, Options: openaiwsv2.RelayOptions{ - WriteTimeout: s.openAIWSWriteTimeout(), + WriteTimeout: s.openAIWSWriteTimeout(), + FirstTurnStartedAt: firstTurnStartedAt, + TakeNextTurnStartedAt: func() time.Time { + startedAt := acceptedTurnStartedAt.Swap(nil) + if startedAt == nil { + return time.Time{} + } + return *startedAt + }, // Passthrough idle is enforced only after a completed turn by // clientFrameConn. The relay-wide activity watchdog would also // terminate a healthy active upstream turn. @@ -1105,6 +1181,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }, OnTurnComplete: func(turn openaiwsv2.RelayTurnResult) { turnNo := int(completedTurns.Add(1)) + if hooks != nil && hooks.TurnStarted != nil && !turn.StartedAt.IsZero() { + hooks.TurnStarted(turnNo, turn.StartedAt) + } turnRequestModel, turnUpstreamModel := usageMeta.turnModels(turn.RequestModel) turnResult := &OpenAIForwardResult{ RequestID: turn.RequestID, @@ -1119,6 +1198,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), UpstreamResponseModel: turn.ResponseModel, UpstreamResponseModelConflict: turn.ResponseModelConflict, + UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(turn.ResponseServiceTier), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, @@ -1152,6 +1232,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } }, AfterClientWrite: func(msgType coderws.MessageType, payload []byte, writeErr error) { + if msgType == coderws.MessageText && writeErr == nil { + eventType, _, _ := parseOpenAIWSEventEnvelope(payload) + markOpenAIWSClientVisibleFailure(c, eventType, payload) + } if msgType == coderws.MessageText && openAIWSPassthroughIsTerminalOutput(payload) { turnLifecycle.finishTerminalWrite(writeErr == nil, clientFrameConn.markTurnCompleted) } @@ -1172,17 +1256,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return nil } eventType, _, _ := parseOpenAIWSEventEnvelope(payload) - if isOpenAIWSTerminalEvent(eventType) { - s.handleOpenAIWSTerminalTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload) - } - if eventType == "error" { - s.handleOpenAIWSErrorEventTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload) - } - if wroteDownstream || eventType != "error" { - return nil + if eventType == "response.created" { + failureAccountSideEffectsApplied = false } errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(payload) - if !isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { + isPreOutputRateLimit := eventType == "error" && !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) + if (eventType == "error" || eventType == "response.failed") && !failureAccountSideEffectsApplied && !isPreOutputRateLimit { + failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, capturedSessionModel, handshakeHeaders, payload) + } + if eventType != "error" { + return nil + } + if wroteDownstream || !isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) { return nil } s.persistOpenAIWSRateLimitSignal(ctx, account, handshakeHeaders, payload, errCodeRaw, errTypeRaw, errMsgRaw) @@ -1193,11 +1278,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(errMsgRaw, openAIWSLogValueMaxLen), ) - return &UpstreamFailoverError{ - StatusCode: http.StatusTooManyRequests, - ResponseBody: append([]byte(nil), payload...), - ResponseHeaders: cloneHeader(handshakeHeaders), - } + return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, payload, errMsgRaw) }, OnTrace: func(event openaiwsv2.RelayTraceEvent) { logOpenAIWSV2Passthrough( @@ -1215,6 +1296,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }, }) if cause := context.Cause(ctx); cause != nil { + if isOpenAIWSSessionPreempted(ctx) { + return errOpenAIWSSessionPreempted + } status := coderws.StatusGoingAway reason := "websocket request canceled" if errors.Is(cause, ErrOpenAIWSIngressLeaseLost) { @@ -1240,6 +1324,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), UpstreamResponseModel: relayResult.ResponseModel, UpstreamResponseModelConflict: relayResult.ResponseModelConflict, + UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(relayResult.ResponseServiceTier), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, @@ -1265,6 +1350,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( ) // 正常路径按 terminal 事件逐 turn 已回调;仅在零 turn 场景兜底回调一次。 if turnCount == 0 && hooks != nil && hooks.AfterTurn != nil { + if hooks.TurnStarted != nil { + hooks.TurnStarted(1, time.Now().Add(-result.Duration)) + } hooks.AfterTurn(1, result, nil) } return nil @@ -1331,6 +1419,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( relayExit.WroteDownstream, ) if hooks != nil && hooks.AfterTurn != nil { + if hooks.TurnStarted != nil { + hooks.TurnStarted(turnCount+1, time.Now().Add(-result.Duration)) + } hooks.AfterTurn(turnCount+1, nil, turnErr) } return turnErr diff --git a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go index de3d5fdf27..ab7d704b04 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go @@ -190,13 +190,18 @@ func startPassthroughLifecycleServer( } func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn { + t.Helper() + return dialPassthroughLifecycleClientWithPayload(t, server, `{"type":"response.create","model":"gpt-5.1","stream":false}`) +} + +func dialPassthroughLifecycleClientWithPayload(t *testing.T, server *httptest.Server, payload string) *coderws.Conn { t.Helper() dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) cancelDial() require.NoError(t, err) writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(payload)) cancelWrite() require.NoError(t, err) return clientConn @@ -221,6 +226,35 @@ func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughCo } } +func TestPassthroughLifecycle_ResponsesLiteFirstFramePinsParallelToolCalls(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_lite","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) + defer server.Close() + clientConn := dialPassthroughLifecycleClientWithPayload(t, server, `{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} + }`) + defer func() { _ = clientConn.CloseNow() }() + + upstreamBody := requirePassthroughUpstreamWrite(t, upstream, 3*time.Second) + require.Equal(t, gjson.False, gjson.GetBytes(upstreamBody, "parallel_tool_calls").Type, string(upstreamBody)) + + event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case <-serverErr: + case <-time.After(3 * time.Second): + t.Fatal("Lite 首帧测试等待 passthrough 退出超时") + } +} + func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) { clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)} clientFrameConn.markTurnCompleted() diff --git a/backend/internal/service/ops_metrics_collector.go b/backend/internal/service/ops_metrics_collector.go index d4f5c32929..4fc9576044 100644 --- a/backend/internal/service/ops_metrics_collector.go +++ b/backend/internal/service/ops_metrics_collector.go @@ -601,24 +601,11 @@ func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsColle sampleAt := time.Now().UTC() - // Prefer cgroup (container) metrics when available. + // CPU: prefer cgroup (container) metrics, falling back to host metrics when + // cgroup CPU accounting is unavailable. if cpuPct := c.tryCgroupCPUPercent(sampleAt); cpuPct != nil { out.cpuUsagePercent = cpuPct } - - cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes() - if cgroupOK { - usedMB := int64(cgroupUsed / bytesPerMB) - out.memoryUsedMB = &usedMB - if cgroupTotal > 0 { - totalMB := int64(cgroupTotal / bytesPerMB) - out.memoryTotalMB = &totalMB - pct := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100) - out.memoryUsagePercent = &pct - } - } - - // Fallback to host metrics if cgroup metrics are unavailable (or incomplete). if out.cpuUsagePercent == nil { if cpuPercents, err := cpu.PercentWithContext(ctx, 0, false); err == nil && len(cpuPercents) > 0 { v := roundTo1DP(cpuPercents[0]) @@ -626,32 +613,60 @@ func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsColle } } - // If total memory isn't available from cgroup (e.g. memory.max = "max"), fill total from host. - if out.memoryUsedMB == nil || out.memoryTotalMB == nil || out.memoryUsagePercent == nil { - if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil && vm != nil { - if out.memoryUsedMB == nil { - usedMB := int64(vm.Used / bytesPerMB) - out.memoryUsedMB = &usedMB - } - if out.memoryTotalMB == nil { - totalMB := int64(vm.Total / bytesPerMB) - out.memoryTotalMB = &totalMB - } - if out.memoryUsagePercent == nil { - if out.memoryUsedMB != nil && out.memoryTotalMB != nil && *out.memoryTotalMB > 0 { - pct := roundTo1DP(float64(*out.memoryUsedMB) / float64(*out.memoryTotalMB) * 100) - out.memoryUsagePercent = &pct - } else { - pct := roundTo1DP(vm.UsedPercent) - out.memoryUsagePercent = &pct - } - } + // Memory: prefer cgroup (container) metrics, but only when the cgroup exposes + // BOTH a current usage and a concrete limit (memory.max != "max"). When the + // limit is missing or the cgroup data is otherwise incomplete, fall back + // ENTIRELY to host metrics. Never mix a container "used" with a host "total": + // doing so reports a misleadingly tiny percentage (e.g. 60MB / 23GB ~= 0.3%). + cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes() + var host *mem.VirtualMemoryStat + if !cgroupOK || cgroupTotal == 0 { + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + host = vm } } + out.memoryUsedMB, out.memoryTotalMB, out.memoryUsagePercent = resolveMemoryStats(cgroupUsed, cgroupTotal, cgroupOK, host) return out, nil } +// resolveMemoryStats picks a single, self-consistent (used, total, percent) +// memory trio from either cgroup (container) or host metrics — never a mix of +// the two. +// +// cgroup metrics are preferred, but only when the cgroup reports BOTH a current +// usage and a concrete limit (i.e. memory.max is a number, not "max", so +// cgroupTotal > 0). If the limit is absent or the cgroup data is otherwise +// incomplete, all three values fall back to the host reading. This avoids the +// classic pitfall of dividing a container "used" by a host "total", which +// wildly understates memory usage (e.g. 60MB / 23GB ~= 0.3%). +func resolveMemoryStats(cgroupUsed, cgroupTotal uint64, cgroupOK bool, host *mem.VirtualMemoryStat) (usedMB *int64, totalMB *int64, usagePercent *float64) { + if cgroupOK && cgroupTotal > 0 { + u := int64(cgroupUsed / bytesPerMB) + t := int64(cgroupTotal / bytesPerMB) + p := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100) + return &u, &t, &p + } + + if host == nil { + return nil, nil, nil + } + + u := int64(host.Used / bytesPerMB) + usedMB = &u + if host.Total > 0 { + t := int64(host.Total / bytesPerMB) + totalMB = &t + p := roundTo1DP(float64(host.Used) / float64(host.Total) * 100) + usagePercent = &p + } else { + // Degenerate: host reported no total. Preserve gopsutil's own percentage. + p := roundTo1DP(host.UsedPercent) + usagePercent = &p + } + return usedMB, totalMB, usagePercent +} + func (c *OpsMetricsCollector) tryCgroupCPUPercent(now time.Time) *float64 { usageNanos, ok := readCgroupCPUUsageNanos() if !ok { diff --git a/backend/internal/service/ops_metrics_collector_memory_test.go b/backend/internal/service/ops_metrics_collector_memory_test.go new file mode 100644 index 0000000000..0a1e8873a8 --- /dev/null +++ b/backend/internal/service/ops_metrics_collector_memory_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "testing" + + "github.com/shirou/gopsutil/v4/mem" + "github.com/stretchr/testify/require" +) + +const ( + testMiB = 1024 * 1024 + testGiB = 1024 * testMiB +) + +// TestResolveMemoryStatsCgroupUsageButUnlimitedFallsBackToHost is the core +// regression: in Docker + cgroup v2 with NO memory limit, memory.current is a +// small container number while memory.max = "max" (so cgroupTotal == 0). The +// old code reported the container "used" against the host "total", producing a +// misleadingly tiny percentage (~0.3%). The fix must fall back ENTIRELY to host +// metrics instead of mixing the two sources. +func TestResolveMemoryStatsCgroupUsageButUnlimitedFallsBackToHost(t *testing.T) { + const cgroupUsed = uint64(64573440) // memory.current, ~61 MiB + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(cgroupUsed, 0 /* memory.max = "max" */, true, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + + // Everything must come from the host, not from the cgroup container value. + require.Equal(t, int64(16*1024), *usedMB, "used must be host used, not container used") + require.Equal(t, int64(24*1024), *totalMB, "total must be host total") + require.InDelta(t, 66.7, *pct, 0.05, "percent must be host-derived, not container/host mix") + + // Guard against the specific bug: container used (~61 MiB) vs host total (~0.3%). + require.NotEqual(t, int64(cgroupUsed/testMiB), *usedMB, "must not report the container used value") + require.Greater(t, *pct, 1.0, "percent must not collapse to the ~0.3%% mixed value") +} + +// TestResolveMemoryStatsExplicitContainerLimitUsesCgroup covers the case where +// a real container memory limit is set: memory.current = 512 MiB and +// memory.max = 2 GiB must yield ~25% entirely from cgroup, ignoring the host. +func TestResolveMemoryStatsExplicitContainerLimitUsesCgroup(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, // deliberately different; must be ignored + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(512*testMiB, 2*testGiB, true, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + + require.Equal(t, int64(512), *usedMB) + require.Equal(t, int64(2048), *totalMB) + require.InDelta(t, 25.0, *pct, 0.05) +} + +// TestResolveMemoryStatsNoCgroupUsesHost covers bare-metal / no-cgroup hosts: +// all three values come from the host reading. +func TestResolveMemoryStatsNoCgroupUsesHost(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + require.Equal(t, int64(16*1024), *usedMB) + require.Equal(t, int64(24*1024), *totalMB) + require.InDelta(t, 66.7, *pct, 0.05) +} + +// TestResolveMemoryStatsNoDataReturnsNil: when neither cgroup nor host data is +// available, all outputs are nil (best-effort, nothing persisted). +func TestResolveMemoryStatsNoDataReturnsNil(t *testing.T) { + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, nil) + require.Nil(t, usedMB) + require.Nil(t, totalMB) + require.Nil(t, pct) +} + +// TestResolveMemoryStatsHostWithoutTotalKeepsGopsutilPercent: degenerate host +// reading with no total still yields a used value and gopsutil's own percentage. +func TestResolveMemoryStatsHostWithoutTotalKeepsGopsutilPercent(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 8 * testGiB, + Total: 0, + UsedPercent: 42.5, + } + + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, host) + + require.NotNil(t, usedMB) + require.Nil(t, totalMB) + require.NotNil(t, pct) + require.Equal(t, int64(8*1024), *usedMB) + require.InDelta(t, 42.5, *pct, 0.05) +} diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index b1d9b9f57c..c651b9184d 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -439,17 +439,8 @@ func (s *OpsService) RecordErrorBatch(ctx context.Context, entries []*OpsInsertE } if _, err := s.opsRepo.BatchInsertErrorLogs(ctx, prepared); err != nil { - log.Printf("[Ops] RecordErrorBatch failed, fallback to single inserts: %v", err) - var firstErr error - for _, entry := range prepared { - if _, insertErr := s.opsRepo.InsertErrorLog(ctx, entry); insertErr != nil { - log.Printf("[Ops] RecordErrorBatch fallback insert failed: %v", insertErr) - if firstErr == nil { - firstErr = insertErr - } - } - } - return firstErr + log.Printf("[Ops] RecordErrorBatch failed: %v", err) + return err } return nil } diff --git a/backend/internal/service/ops_service_batch_test.go b/backend/internal/service/ops_service_batch_test.go index a027f48737..e6a87191dc 100644 --- a/backend/internal/service/ops_service_batch_test.go +++ b/backend/internal/service/ops_service_batch_test.go @@ -69,7 +69,7 @@ func TestOpsServiceRecordErrorBatch_SanitizesAndBatches(t *testing.T) { require.False(t, second.CreatedAt.IsZero()) } -func TestOpsServiceRecordErrorBatch_FallsBackToSingleInsert(t *testing.T) { +func TestOpsServiceRecordErrorBatch_DoesNotFallbackToSingleInsertsWhenBatchFails(t *testing.T) { t.Parallel() var ( @@ -92,9 +92,9 @@ func TestOpsServiceRecordErrorBatch_FallsBackToSingleInsert(t *testing.T) { {ErrorMessage: "first"}, {ErrorMessage: "second"}, }) - require.NoError(t, err) + require.Error(t, err) require.Equal(t, 1, batchCalls) - require.Equal(t, 2, singleCalls) + require.Zero(t, singleCalls) } func TestOpsServiceRecordErrorPersistsExplicitAccountAuthStatusZero(t *testing.T) { diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 35a97cc2f5..509620fa1b 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/gin-gonic/gin" ) @@ -15,6 +16,7 @@ const ( OpsUpstreamErrorMessageKey = "ops_upstream_error_message" OpsUpstreamErrorDetailKey = "ops_upstream_error_detail" OpsUpstreamErrorsKey = "ops_upstream_errors" + OpsUpstreamModelKey = "ops_upstream_model" // Optional stage latencies (milliseconds) for troubleshooting and alerting. OpsAuthLatencyMsKey = "ops_auth_latency_ms" @@ -36,7 +38,9 @@ const ( // 上就地(in-band)补发错误帧时记录的 OpsStreamError。因为 wire 状态码停留在 200, // ops_error_logger 的 status>=400 采集路径永远不会触发,这类流内失败 //(例如等待并发槽位超时后回退的限流、Wait 后二次计费校验失败)本会在错误看板里隐形。 - OpsStreamErrorKey = "ops_stream_error" + OpsStreamErrorKey = "ops_stream_error" + OpsStreamErrorsKey = "ops_stream_errors" + OpsStreamTurnKey = "ops_stream_turn" // Client-side configuration denials should remain visible in ops_error_logs, // but should be excluded from SLA/error-rate calculations. @@ -44,13 +48,14 @@ const ( // ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。 ResponseCommittedKey = "response_committed" - OpsClientBusinessLimitedKey = "ops_client_business_limited" - OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason" - OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction" - OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable" - OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned" - OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate" - OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied" + OpsClientBusinessLimitedKey = "ops_client_business_limited" + OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason" + OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction" + OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable" + OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned" + OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate" + OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied" + OpsClientBusinessLimitedReasonLocalModelConfiguration = "local_model_configuration" ) func MarkResponseCommitted(c *gin.Context) { c.Set(ResponseCommittedKey, true) } @@ -71,6 +76,26 @@ func SetOpsLatencyMs(c *gin.Context, key string, value int64) { c.Set(key, value) } +// SetOpsUpstreamModel stores only the effective model slug for final Ops +// attribution. Call it immediately before an upstream attempt is dispatched. +func SetOpsUpstreamModel(c *gin.Context, model string) { + if c == nil { + return + } + if model = strings.TrimSpace(model); model != "" { + c.Set(OpsUpstreamModelKey, model) + } +} + +// ClearOpsUpstreamModel invalidates attempt-scoped model attribution before a +// newly selected account starts credential resolution or upstream dispatch. +func ClearOpsUpstreamModel(c *gin.Context) { + if c == nil { + return + } + c.Set(OpsUpstreamModelKey, "") +} + func MarkOpsClientBusinessLimited(c *gin.Context, reason string) { if c == nil { return @@ -93,6 +118,18 @@ func HasOpsClientBusinessLimited(c *gin.Context) bool { return marked } +func OpsClientBusinessLimitedReason(c *gin.Context) string { + if c == nil { + return "" + } + v, ok := c.Get(OpsClientBusinessLimitedReasonKey) + if !ok { + return "" + } + reason, _ := v.(string) + return strings.TrimSpace(reason) +} + // OpsStreamError 描述网关在「响应状态已固化为 200」之后(keepalive ping 或部分数据 // 已 flush)就地以 SSE error 帧形式返回的错误。由于 HTTP 状态码停留在 200, // 而 ops_error_logger 以 status>=400 为采集触发条件,这类流内失败 @@ -113,6 +150,32 @@ type OpsStreamError struct { // CountTowardsSLA 表示虽然 wire 状态已固化为 200,请求在应用语义上仍然失败, // Ops 应使用 IntendedStatus 计入错误率/SLA。 CountTowardsSLA bool + // Turn identifies a WebSocket turn. HTTP/SSE requests leave it at zero. + Turn int + // SkipMonitoring snapshots the rule decision for this visible failure. + SkipMonitoring bool + AccountID int64 + UpstreamModel string + UpstreamStatus int + UpstreamMessage string + UpstreamDetail string + UpstreamErrors []*OpsUpstreamErrorEvent +} + +const maxOpsStreamErrorsPerRequest = 64 + +// BeginOpsStreamTurn scopes first-wins deduplication to one WebSocket turn. +func BeginOpsStreamTurn(c *gin.Context, turn int) { + if c == nil || turn <= 0 { + return + } + c.Set(OpsStreamTurnKey, turn) + // Rule and attempt state is turn-scoped on a long-lived WS connection. + c.Set(OpsSkipPassthroughKey, false) + c.Set(OpsUpstreamErrorsKey, []*OpsUpstreamErrorEvent{}) + c.Set(OpsUpstreamStatusCodeKey, 0) + c.Set(OpsUpstreamErrorMessageKey, "") + c.Set(OpsUpstreamErrorDetailKey, "") } // MarkOpsStreamError 记录一次就地 SSE 错误,供 ops 日志采集。 @@ -143,15 +206,101 @@ func markOpsStreamError(c *gin.Context, streamErr OpsStreamError) { if c == nil { return } - if _, exists := c.Get(OpsStreamErrorKey); exists { - return - } streamErr.ErrType = strings.TrimSpace(streamErr.ErrType) streamErr.Code = strings.TrimSpace(streamErr.Code) streamErr.Message = strings.TrimSpace(streamErr.Message) + streamErr.SkipMonitoring = currentOpsFailureSkipMonitoring(c) + snapshotOpsStreamErrorContext(c, &streamErr) + if GetOpenAIClientTransport(c) == OpenAIClientTransportWS { + if value, ok := c.Get(OpsStreamTurnKey); ok { + streamErr.Turn, _ = value.(int) + } + var errorsForRequest []OpsStreamError + if value, ok := c.Get(OpsStreamErrorsKey); ok { + errorsForRequest, _ = value.([]OpsStreamError) + } + if len(errorsForRequest) > 0 && errorsForRequest[len(errorsForRequest)-1].Turn == streamErr.Turn { + return + } + errorsForRequest = append(errorsForRequest, streamErr) + if len(errorsForRequest) > maxOpsStreamErrorsPerRequest { + errorsForRequest = append([]OpsStreamError(nil), errorsForRequest[len(errorsForRequest)-maxOpsStreamErrorsPerRequest:]...) + } + c.Set(OpsStreamErrorsKey, errorsForRequest) + c.Set(OpsStreamErrorKey, streamErr) + return + } + if _, exists := c.Get(OpsStreamErrorKey); exists { + return + } c.Set(OpsStreamErrorKey, streamErr) } +func snapshotOpsStreamErrorContext(c *gin.Context, streamErr *OpsStreamError) { + if c == nil || streamErr == nil { + return + } + if c.Request != nil { + if accountID, ok := c.Request.Context().Value(ctxkey.AccountID).(int64); ok && accountID > 0 { + streamErr.AccountID = accountID + } + } + if value, ok := c.Get(OpsUpstreamModelKey); ok { + streamErr.UpstreamModel, _ = value.(string) + streamErr.UpstreamModel = strings.TrimSpace(streamErr.UpstreamModel) + } + if value, ok := c.Get(OpsUpstreamStatusCodeKey); ok { + switch status := value.(type) { + case int: + streamErr.UpstreamStatus = status + case int64: + streamErr.UpstreamStatus = int(status) + } + } + if value, ok := c.Get(OpsUpstreamErrorMessageKey); ok { + streamErr.UpstreamMessage, _ = value.(string) + streamErr.UpstreamMessage = strings.TrimSpace(streamErr.UpstreamMessage) + } + if value, ok := c.Get(OpsUpstreamErrorDetailKey); ok { + streamErr.UpstreamDetail, _ = value.(string) + streamErr.UpstreamDetail = strings.TrimSpace(streamErr.UpstreamDetail) + } + if value, ok := c.Get(OpsUpstreamErrorsKey); ok { + if events, ok := value.([]*OpsUpstreamErrorEvent); ok { + streamErr.UpstreamErrors = make([]*OpsUpstreamErrorEvent, 0, len(events)) + for _, event := range events { + if event == nil { + streamErr.UpstreamErrors = append(streamErr.UpstreamErrors, nil) + continue + } + copyOfEvent := *event + streamErr.UpstreamErrors = append(streamErr.UpstreamErrors, ©OfEvent) + } + } + } +} + +func currentOpsFailureSkipMonitoring(c *gin.Context) bool { + if c == nil { + return false + } + if value, ok := c.Get(OpsSkipPassthroughKey); ok { + if skip, _ := value.(bool); skip { + return true + } + } + if value, ok := c.Get(OpsUpstreamErrorsKey); ok { + if events, ok := value.([]*OpsUpstreamErrorEvent); ok { + for i := len(events) - 1; i >= 0; i-- { + if events[i] != nil { + return events[i].SkipMonitoring + } + } + } + } + return false +} + // GetOpsStreamError 返回本请求记录的就地 SSE 错误(若有)。 func GetOpsStreamError(c *gin.Context) (OpsStreamError, bool) { if c == nil { @@ -165,6 +314,21 @@ func GetOpsStreamError(c *gin.Context) (OpsStreamError, bool) { return se, ok } +func GetOpsStreamErrors(c *gin.Context) []OpsStreamError { + if c == nil { + return nil + } + if value, ok := c.Get(OpsStreamErrorsKey); ok { + if errorsForRequest, ok := value.([]OpsStreamError); ok && len(errorsForRequest) > 0 { + return append([]OpsStreamError(nil), errorsForRequest...) + } + } + if streamErr, ok := GetOpsStreamError(c); ok { + return []OpsStreamError{streamErr} + } + return nil +} + // SetOpsUpstreamError is the exported wrapper for setOpsUpstreamError, used by // handler-layer code (e.g. failover-exhausted paths) that needs to record the // original upstream status code before mapping it to a client-facing code. @@ -222,6 +386,12 @@ type OpsUpstreamErrorEvent struct { Message string `json:"message,omitempty"` Detail string `json:"detail,omitempty"` + + // SkipMonitoring is request-local rule state. It is intentionally excluded + // from persisted attempt JSON. The logger consults it only when this event is + // the final client-visible failure; recovered attempts remain provider-health + // telemetry and do not count as failed requests. + SkipMonitoring bool `json:"-"` } func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) { @@ -259,11 +429,10 @@ func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) { checkSkipMonitoringForUpstreamEvent(c, &evCopy) } -// checkSkipMonitoringForUpstreamEvent checks whether the upstream error event -// matches a passthrough rule with skip_monitoring=true and, if so, sets the -// OpsSkipPassthroughKey on the context. This ensures intermediate retry / -// failover errors (which never go through the final applyErrorPassthroughRule -// path) can still suppress ops_error_logs recording. +// checkSkipMonitoringForUpstreamEvent snapshots whether this attempt matches a +// skip_monitoring passthrough rule. The final failure decides whether the +// request error is hidden; an intermediate recovered attempt cannot suppress a +// later client-visible failure. func checkSkipMonitoringForUpstreamEvent(c *gin.Context, ev *OpsUpstreamErrorEvent) { if ev.UpstreamStatusCode == 0 { return @@ -284,7 +453,7 @@ func checkSkipMonitoringForUpstreamEvent(c *gin.Context, ev *OpsUpstreamErrorEve rule := svc.MatchRule(ev.Platform, ev.UpstreamStatusCode, []byte(body)) if rule != nil && rule.SkipMonitoring { - c.Set(OpsSkipPassthroughKey, true) + ev.SkipMonitoring = true } } diff --git a/backend/internal/service/overload_cooldown_test.go b/backend/internal/service/overload_cooldown_test.go index ef5e7fd122..92fc743d1e 100644 --- a/backend/internal/service/overload_cooldown_test.go +++ b/backend/internal/service/overload_cooldown_test.go @@ -36,10 +36,16 @@ func (r *errSettingRepo) Get(_ context.Context, _ string) (*Setting, error) { type overloadAccountRepoStub struct { mockAccountRepoForGemini overloadCalls int + errorCalls int lastOverloadID int64 lastOverloadEnd time.Time } +func (r *overloadAccountRepoStub) SetError(_ context.Context, _ int64, _ string) error { + r.errorCalls++ + return nil +} + func (r *overloadAccountRepoStub) SetOverloaded(_ context.Context, id int64, until time.Time) error { r.overloadCalls++ r.lastOverloadID = id @@ -269,6 +275,64 @@ func TestHandle529_DBReadError_FallsBackToConfig(t *testing.T) { require.WithinDuration(t, before.Add(7*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) } +func TestHandleUpstreamError_529RespectsAccountPolicies(t *testing.T) { + tests := []struct { + name string + credentials map[string]any + }{ + { + name: "pool mode", + credentials: map[string]any{"pool_mode": true}, + }, + { + name: "custom code filter excludes 529", + credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &overloadAccountRepoStub{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 101, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: tt.credentials, + } + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, 529, nil, []byte(`{"error":{"message":"overloaded"}}`)) + + require.False(t, shouldDisable) + require.Zero(t, repo.overloadCalls) + require.Zero(t, repo.errorCalls) + }) + } +} + +func TestHandleUpstreamError_529CustomCodeDisablesInsteadOfOverloadCooldown(t *testing.T) { + repo := &overloadAccountRepoStub{} + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ + ID: 102, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(529)}, + }, + } + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, 529, nil, []byte(`{"error":{"message":"overloaded"}}`)) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.errorCalls) + require.Zero(t, repo.overloadCalls) +} + // =========================================================================== // Model: defaults & JSON round-trip // =========================================================================== diff --git a/backend/internal/service/plugin_compatibility.go b/backend/internal/service/plugin_compatibility.go new file mode 100644 index 0000000000..02c3ba3444 --- /dev/null +++ b/backend/internal/service/plugin_compatibility.go @@ -0,0 +1,104 @@ +package service + +import ( + "fmt" + "strings" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "golang.org/x/mod/semver" +) + +type PluginHostInfo struct { + Version string + BuildType string +} + +func EvaluatePluginCompatibility(manifest PluginManifest, host PluginHostInfo) PluginCompatibility { + result := PluginCompatibility{ + CurrentSub2API: host.Version, + RequiredSub2API: manifest.Requires.Sub2API, + RecommendedSub2API: manifest.Requires.RecommendedSub2APIVersion, + PluginProtocol: manifest.Requires.PluginProtocol, + TransportAPI: manifest.Requires.TransportAPI, + UIBridge: manifest.Requires.UIBridge, + } + if manifest.Requires.PluginProtocol != pluginv1.ProtocolVersion || + manifest.Requires.TransportAPI != pluginv1.TransportAPIVersion || + manifest.Requires.UIBridge != pluginv1.UIBridgeVersion { + result.Status = "incompatible" + result.Message = "插件协议版本与当前 Sub2API 不兼容" + return result + } + if !matchesSemverRange(host.Version, manifest.Requires.Sub2API) { + result.Status = "incompatible" + result.Message = fmt.Sprintf("当前 Sub2API %s 不满足插件要求 %s", host.Version, manifest.Requires.Sub2API) + return result + } + result.Compatible = true + for _, tested := range manifest.Requires.TestedSub2APIVersions { + if normalizeSemver(tested) == normalizeSemver(host.Version) { + result.Tested = true + break + } + } + if result.Tested { + result.Status = "compatible" + result.Message = "当前 Sub2API 版本已由插件声明测试" + } else { + result.Status = "untested" + result.Message = "版本范围兼容,但插件未声明已测试当前 Sub2API 版本" + } + return result +} + +func normalizeSemver(version string) string { + v := strings.TrimSpace(version) + if v == "" { + return "" + } + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return "" + } + return v +} + +func matchesSemverRange(version, expression string) bool { + v := normalizeSemver(version) + if v == "" { + return false + } + tokens := strings.Fields(strings.ReplaceAll(expression, ",", " ")) + if len(tokens) == 0 { + return false + } + for _, token := range tokens { + op := "=" + raw := token + for _, candidate := range []string{">=", "<=", ">", "<", "="} { + if strings.HasPrefix(token, candidate) { + op = candidate + raw = strings.TrimSpace(strings.TrimPrefix(token, candidate)) + break + } + } + bound := normalizeSemver(raw) + if bound == "" { + return false + } + comparison := semver.Compare(v, bound) + matched := map[string]bool{ + ">=": comparison >= 0, + "<=": comparison <= 0, + ">": comparison > 0, + "<": comparison < 0, + "=": comparison == 0, + }[op] + if !matched { + return false + } + } + return true +} diff --git a/backend/internal/service/plugin_compatibility_test.go b/backend/internal/service/plugin_compatibility_test.go new file mode 100644 index 0000000000..f819c41d5e --- /dev/null +++ b/backend/internal/service/plugin_compatibility_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "testing" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvaluatePluginCompatibility(t *testing.T) { + manifest := testPluginManifest(nil) + host := PluginHostInfo{Version: "0.1.179", BuildType: "release"} + + result := EvaluatePluginCompatibility(manifest, host) + require.True(t, result.Compatible) + assert.True(t, result.Tested) + assert.Equal(t, "compatible", result.Status) + + manifest.Requires.TestedSub2APIVersions = []string{"0.1.178"} + result = EvaluatePluginCompatibility(manifest, host) + require.True(t, result.Compatible) + assert.False(t, result.Tested) + assert.Equal(t, "untested", result.Status) + + manifest.Requires.Sub2API = ">=0.2.0 <0.3.0" + result = EvaluatePluginCompatibility(manifest, host) + assert.False(t, result.Compatible) + assert.Equal(t, "incompatible", result.Status) +} + +func TestEvaluatePluginCompatibilityRejectsProtocolMismatch(t *testing.T) { + manifest := testPluginManifest(nil) + manifest.Requires.PluginProtocol = pluginv1.ProtocolVersion + 1 + + result := EvaluatePluginCompatibility(manifest, PluginHostInfo{Version: "0.1.179"}) + + assert.False(t, result.Compatible) + assert.Equal(t, "incompatible", result.Status) +} + +func TestMatchesSemverRange(t *testing.T) { + assert.True(t, matchesSemverRange("0.1.179", ">=0.1.170, <0.2.0")) + assert.True(t, matchesSemverRange("v1.2.3", "=1.2.3")) + assert.False(t, matchesSemverRange("0.1.169", ">=0.1.170 <0.2.0")) + assert.False(t, matchesSemverRange("dev", ">=0.1.0")) + assert.False(t, matchesSemverRange("0.1.179", "^0.1.0")) +} diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go new file mode 100644 index 0000000000..f9f06e3bed --- /dev/null +++ b/backend/internal/service/plugin_manager.go @@ -0,0 +1,1091 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" +) + +const ( + pluginConfigMaxBytes = 4 * 1024 * 1024 + pluginUIAssetMaxBytes = 32 * 1024 * 1024 + pluginReconcilePeriod = time.Second + pluginHealthTimeout = 5 * time.Second + pluginUITokenPrefix = "sub2api:plugin-ui:v1:" +) + +type pluginRoute struct { + pluginID int64 + runtime *pluginRuntime + rolloutPercent int + unavailable string +} + +// PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。 +type PluginManager struct { + repo PluginRepository + encryptor SecretEncryptor + cfg *config.Config + hostInfo PluginHostInfo + installer *PluginPackageInstaller + + operationMu sync.Mutex + mu sync.Mutex + runtimes map[int64]*pluginRuntime + localInstallations map[int64]*PluginInstallation + started bool + reconcileCancel context.CancelFunc + reconcileDone chan struct{} + route atomic.Pointer[pluginRoute] +} + +func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager { + return &PluginManager{ + repo: repo, + encryptor: encryptor, + cfg: cfg, + hostInfo: hostInfo, + installer: NewPluginPackageInstaller(cfg, hostInfo), + runtimes: make(map[int64]*pluginRuntime), + localInstallations: make(map[int64]*PluginInstallation), + } +} + +func (m *PluginManager) MaxUploadBytes() int64 { + if m == nil || m.cfg == nil { + return 0 + } + return m.cfg.Plugins.MaxUploadBytes +} + +func (m *PluginManager) Start(ctx context.Context) error { + m.operationMu.Lock() + m.mu.Lock() + if m.started { + m.mu.Unlock() + m.operationMu.Unlock() + return nil + } + if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil { + m.mu.Unlock() + m.operationMu.Unlock() + return fmt.Errorf("创建插件运行目录: %w", err) + } + reconcileCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + m.started = true + m.reconcileCancel = cancel + m.reconcileDone = make(chan struct{}) + done := m.reconcileDone + m.mu.Unlock() + m.operationMu.Unlock() + + go m.reconcileLoop(reconcileCtx, done) + if err := m.reconcileOnce(reconcileCtx); err != nil { + slog.Warn("plugin_initial_reconcile_failed", "error", err) + } + return nil +} + +func (m *PluginManager) Stop() { + m.mu.Lock() + cancel := m.reconcileCancel + done := m.reconcileDone + m.reconcileCancel = nil + m.reconcileDone = nil + m.mu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + <-done + } + m.operationMu.Lock() + m.mu.Lock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for _, runtime := range m.runtimes { + runtimes = append(runtimes, runtime) + } + m.runtimes = make(map[int64]*pluginRuntime) + m.route.Store(nil) + m.started = false + m.mu.Unlock() + m.operationMu.Unlock() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) + } +} + +func (m *PluginManager) List(ctx context.Context) ([]*PluginInstallation, error) { + plugins, err := m.repo.List(ctx) + if err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + route := m.route.Load() + for _, installation := range plugins { + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + if runtime := m.runtimes[installation.ID]; runtime != nil && !runtime.client.Exited() { + installation.RuntimeHealthy = true + installation.RuntimeMessage = "插件进程运行中" + } else if installation.State == PluginStateEnabled { + installation.RuntimeMessage = installation.LastError + } + if route != nil && route.pluginID == installation.ID && route.runtime == nil { + installation.RuntimeMessage = route.unavailable + } + } + return plugins, nil +} + +func (m *PluginManager) Get(ctx context.Context, id int64) (*PluginInstallation, error) { + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited() + if installation.RuntimeHealthy { + installation.RuntimeMessage = "插件进程运行中" + } else if route := m.route.Load(); route != nil && route.pluginID == id { + installation.RuntimeMessage = route.unavailable + } + return installation, nil +} + +func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + packageInfo, err := m.installer.Install(ctx, reader, installedBy) + if err != nil { + return nil, err + } + var previous *PluginInstallation + if existing, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil { + if existing.State == PluginStateEnabled || hasEnabledOpenAIBinding(existing.Bindings) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(errors.New("请先停用当前插件,再上传同 ID 的新版本"), cleanupErr) + } + previous = existing + } else if !errors.Is(getErr, sql.ErrNoRows) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(getErr, cleanupErr) + } + bindings := make([]PluginBinding, 0, len(packageInfo.Manifest.Capabilities)) + for _, capability := range packageInfo.Manifest.SortedCapabilities() { + bindings = append(bindings, PluginBinding{ + Capability: capability.ID, + Platform: capability.Platform, + AccountType: capability.AccountType, + Enabled: false, + RolloutPercent: 100, + }) + } + installed, err := m.repo.Install(ctx, packageInfo, bindings) + if err != nil { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(err, cleanupErr) + } + local := *packageInfo + local.ID = installed.ID + local.ConfigEncrypted = installed.ConfigEncrypted + local.Bindings = append([]PluginBinding(nil), installed.Bindings...) + m.mu.Lock() + localPrevious := m.localInstallations[installed.ID] + m.localInstallations[installed.ID] = &local + m.mu.Unlock() + if previous != nil { + if cleanupErr := m.cleanupInstallationFiles(previous); cleanupErr != nil { + slog.Warn("plugin_previous_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + if localPrevious != nil && (localPrevious.InstallPath != previous.InstallPath || localPrevious.ArtifactPath != previous.ArtifactPath) { + if cleanupErr := m.cleanupInstallationFiles(localPrevious); cleanupErr != nil { + slog.Warn("plugin_previous_local_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + } + } + return m.Get(ctx, installed.ID) +} + +func (m *PluginManager) cleanupInstallationFiles(installation *PluginInstallation) error { + if installation == nil { + return nil + } + var cleanupErr error + for _, path := range []string{installation.ArtifactPath, installation.InstallPath} { + if err := m.removeManagedPath(path); err != nil { + cleanupErr = errors.Join(cleanupErr, err) + } + } + return cleanupErr +} + +func (m *PluginManager) reconcileLoop(ctx context.Context, done chan struct{}) { + defer close(done) + ticker := time.NewTicker(pluginReconcilePeriod) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.reconcileOnce(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("plugin_reconcile_failed", "error", err) + } + } + } +} + +// reconcileOnce 以数据库中的绑定为权威状态,让每个实例独立恢复并启动同一插件。 +func (m *PluginManager) reconcileOnce(ctx context.Context) error { + m.operationMu.Lock() + defer m.operationMu.Unlock() + + installations, err := m.repo.List(ctx) + if err != nil { + // 无法读取权威绑定状态时不能假设插件未启用,否则会把 OAuth 请求静默回落到旧直连路径。 + m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取") + return fmt.Errorf("读取插件启用状态: %w", err) + } + m.cleanupStaleLocalInstallations(installations) + var enabled *PluginInstallation + for _, installation := range installations { + if !hasEnabledOpenAIBinding(installation.Bindings) { + continue + } + if enabled != nil { + err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用") + m.publishUnavailableRoute(enabled.ID, 100, err.Error()) + return err + } + enabled = installation + } + if enabled == nil { + for _, installation := range installations { + if installation.State != PluginStateStarting || !m.startingStateExpired(installation) { + continue + } + if err := m.repo.UpdateState( + ctx, installation.ID, PluginStateDisabled, "插件启动超时,已自动恢复为停用状态", nil, + installation.BinarySHA256, PluginStateStarting, + ); err != nil && !errors.Is(err, ErrPluginStateChanged) { + return fmt.Errorf("恢复超时插件状态: %w", err) + } + } + runtimes := m.detachAllRuntimes() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) + } + return nil + } + + rollout := bindingRollout(enabled.Bindings) + current := m.route.Load() + if current != nil && current.pluginID == enabled.ID && current.runtime != nil && + !current.runtime.client.Exited() && current.rolloutPercent == rollout && + current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 && + current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted { + healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout) + healthErr := current.runtime.checkHealth(healthCtx) + cancel() + if healthErr != nil { + if stateErr := m.markRuntimeUnavailable(current, healthErr.Error()); stateErr != nil { + return errors.Join(healthErr, stateErr) + } + return healthErr + } + if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) { + return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted) + } + return nil + } + if enabled.State == PluginStateStarting && !m.startingStateExpired(enabled) { + if current == nil { + m.route.Store(&pluginRoute{pluginID: enabled.ID, rolloutPercent: rollout, unavailable: "插件正在其他实例中启动"}) + } + return nil + } + + local, err := m.ensureLocalInstallation(ctx, enabled) + if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) + return err + } + runtime, err := m.prepareRuntime(ctx, local, true) + if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) + return err + } + + // 启动进程期间绑定可能已在其他实例上变化,发布前必须重新确认。 + latest, err := m.repo.GetByID(ctx, enabled.ID) + if err != nil { + runtime.kill() + return err + } + if !hasEnabledOpenAIBinding(latest.Bindings) || latest.BinarySHA256 != enabled.BinarySHA256 || + latest.ConfigEncrypted != enabled.ConfigEncrypted || bindingRollout(latest.Bindings) != rollout { + runtime.kill() + return nil + } + if latest.State == PluginStateStarting && !m.startingStateExpired(latest) { + runtime.kill() + return nil + } + if err := m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted); err != nil { + runtime.kill() + if errors.Is(err, ErrPluginStateChanged) { + return nil + } + return err + } + + m.mu.Lock() + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, candidate := range m.runtimes { + if candidate != runtime { + candidate.draining.Store(true) + stale = append(stale, candidate) + } + if id != enabled.ID { + delete(m.runtimes, id) + } + } + m.runtimes[enabled.ID] = runtime + m.route.Store(&pluginRoute{pluginID: enabled.ID, runtime: runtime, rolloutPercent: rollout}) + m.mu.Unlock() + for _, candidate := range stale { + candidate.drain(10 * time.Second) + } + return nil +} + +func (m *PluginManager) startingStateExpired(installation *PluginInstallation) bool { + if installation == nil || installation.UpdatedAt.IsZero() { + return false + } + startTimeout := 15 * time.Second + if m.cfg != nil && m.cfg.Plugins.StartTimeoutSeconds > 0 { + startTimeout = time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second + } + recoveryDelay := startTimeout + 45*time.Second + if recoveryDelay < time.Minute { + recoveryDelay = time.Minute + } + return time.Since(installation.UpdatedAt) > recoveryDelay +} + +func (m *PluginManager) detachAllRuntimes() []*pluginRuntime { + m.mu.Lock() + defer m.mu.Unlock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + runtimes = append(runtimes, runtime) + delete(m.runtimes, id) + } + m.route.Store(nil) + return runtimes +} + +func (m *PluginManager) publishUnavailableRoute(pluginID int64, rollout int, message string) { + m.mu.Lock() + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + stale = append(stale, runtime) + delete(m.runtimes, id) + } + m.route.Store(&pluginRoute{pluginID: pluginID, rolloutPercent: rollout, unavailable: message}) + m.mu.Unlock() + for _, runtime := range stale { + runtime.drain(10 * time.Second) + } +} + +func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installation *PluginInstallation) (*PluginInstallation, error) { + if installation == nil { + return nil, errors.New("插件安装记录为空") + } + m.mu.Lock() + local := m.localInstallations[installation.ID] + m.mu.Unlock() + if local != nil && local.BinarySHA256 == installation.BinarySHA256 && local.Version == installation.Version { + if err := verifyLocalPluginBinary(local, m.installer.RootDir()); err == nil { + return mergeLocalInstallation(local, installation), nil + } + } + if err := verifyLocalPluginBinary(installation, m.installer.RootDir()); err == nil { + local = mergeLocalInstallation(installation, installation) + m.mu.Lock() + m.localInstallations[installation.ID] = local + m.mu.Unlock() + return local, nil + } + + artifact, err := m.repo.GetArtifact(ctx, installation.ID) + if err != nil { + return nil, fmt.Errorf("读取插件包原件: %w", err) + } + if len(artifact) == 0 { + return nil, errors.New("插件包原件缺失,请重新上传插件") + } + restored, err := m.installer.Install(ctx, bytes.NewReader(artifact), installation.InstalledBy) + if err != nil { + return nil, fmt.Errorf("恢复并复验插件包: %w", err) + } + if !samePluginPackage(restored, installation) { + cleanupErr := m.cleanupInstallationFiles(restored) + return nil, errors.Join(errors.New("数据库插件包与安装记录不一致"), cleanupErr) + } + local = mergeLocalInstallation(restored, installation) + m.mu.Lock() + m.localInstallations[installation.ID] = local + m.mu.Unlock() + return local, nil +} + +// cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。 +// 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。 +func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) { + persisted := make(map[int64]*PluginInstallation, len(installations)) + for _, installation := range installations { + if installation != nil { + persisted[installation.ID] = installation + } + } + m.mu.Lock() + stale := make([]*PluginInstallation, 0) + for id, local := range m.localInstallations { + current := persisted[id] + if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version { + stale = append(stale, local) + delete(m.localInstallations, id) + } + } + m.mu.Unlock() + for _, local := range stale { + if err := m.cleanupInstallationFiles(local); err != nil { + slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err) + } + } +} + +func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation { + merged := *persisted + merged.ArtifactData = nil + merged.ArtifactPath = local.ArtifactPath + merged.InstallPath = local.InstallPath + merged.BinaryPath = local.BinaryPath + merged.Bindings = append([]PluginBinding(nil), persisted.Bindings...) + return &merged +} + +func samePluginPackage(local, persisted *PluginInstallation) bool { + if local == nil || persisted == nil || local.PluginKey != persisted.PluginKey || + local.Version != persisted.Version || local.BinarySHA256 != persisted.BinarySHA256 { + return false + } + localManifest, localErr := json.Marshal(local.Manifest) + persistedManifest, persistedErr := json.Marshal(persisted.Manifest) + return localErr == nil && persistedErr == nil && bytes.Equal(localManifest, persistedManifest) +} + +func verifyLocalPluginBinary(installation *PluginInstallation, root string) error { + if installation == nil { + return errors.New("插件安装记录为空") + } + rootPath, err := filepath.Abs(root) + if err != nil { + return err + } + installPath, err := filepath.Abs(installation.InstallPath) + if err != nil { + return err + } + relative, err := filepath.Rel(rootPath, installPath) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("插件安装目录不在受管目录内") + } + runtimeEntry, ok := installation.Manifest.Runtimes[installation.Manifest.RuntimeKey()] + if !ok { + return errors.New("插件未声明当前平台运行时") + } + binaryPath, err := safePluginJoin(installPath, runtimeEntry.Path) + if err != nil { + return err + } + data, err := os.ReadFile(binaryPath) + if err != nil { + return err + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != installation.BinarySHA256 { + return errors.New("本地插件二进制哈希不匹配") + } + installation.BinaryPath = binaryPath + return nil +} + +func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested bool, rolloutPercent int) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + if rolloutPercent < 1 || rolloutPercent > 100 { + return nil, errors.New("灰度比例必须在 1 到 100 之间") + } + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if active := m.route.Load(); active != nil && active.pluginID != id { + return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件") + } + if installation.State == PluginStateEnabled && hasEnabledOpenAIBinding(installation.Bindings) { + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited() + if installation.RuntimeHealthy { + return installation, nil + } + } + compatibility := EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + if !compatibility.Compatible { + stateErr := m.repo.UpdateState(ctx, id, PluginStateIncompatible, compatibility.Message, nil, installation.BinarySHA256, installation.State) + return nil, errors.Join(errors.New(compatibility.Message), stateErr) + } + if !compatibility.Tested && !acceptUntested { + return nil, errors.New("插件未声明已测试当前 Sub2API 版本,需要管理员确认后启用") + } + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + originalBindings := append([]PluginBinding(nil), installation.Bindings...) + for index := range installation.Bindings { + installation.Bindings[index].Enabled = true + installation.Bindings[index].RolloutPercent = rolloutPercent + } + if err := m.repo.BeginEnable(ctx, id, installation.BinarySHA256, installation.State); err != nil { + return nil, err + } + runtime, err := m.prepareRuntime(ctx, installation, true) + if err != nil { + stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting) + cancel() + if hasEnabledOpenAIBinding(originalBindings) { + m.route.Store(&pluginRoute{pluginID: id, rolloutPercent: bindingRollout(originalBindings), unavailable: err.Error()}) + } + return nil, errors.Join(err, stateErr) + } + now := time.Now() + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil { + runtime.kill() + if errors.Is(err, ErrPluginStateChanged) { + return nil, err + } + stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting) + cancel() + return nil, errors.Join(err, stateErr) + } + m.mu.Lock() + m.publishRuntimeLocked(installation, runtime) + m.mu.Unlock() + result, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + result.Compatibility = compatibility + result.RuntimeHealthy = true + result.RuntimeMessage = "插件进程运行中" + return result, nil +} + +func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + m.mu.Lock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + m.mu.Unlock() + return nil, err + } + for index := range installation.Bindings { + installation.Bindings[index].Enabled = false + } + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil { + m.mu.Unlock() + return nil, err + } + runtime := m.removeRuntimeLocked(id) + m.mu.Unlock() + if runtime != nil { + runtime.drain(10 * time.Second) + } + return m.Get(ctx, id) +} + +func (m *PluginManager) Delete(ctx context.Context, id int64) error { + m.operationMu.Lock() + defer m.operationMu.Unlock() + m.mu.Lock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + m.mu.Unlock() + return err + } + if installation.State == PluginStateEnabled || hasEnabledOpenAIBinding(installation.Bindings) { + m.mu.Unlock() + return errors.New("请先停用插件,再执行卸载") + } + if err := m.repo.Delete(ctx, id, installation.BinarySHA256); err != nil { + m.mu.Unlock() + return err + } + runtime := m.removeRuntimeLocked(id) + local := m.localInstallations[id] + delete(m.localInstallations, id) + m.mu.Unlock() + if runtime != nil { + runtime.drain(10 * time.Second) + } + cleanupErr := m.cleanupInstallationFiles(installation) + if local != nil && (local.InstallPath != installation.InstallPath || local.ArtifactPath != installation.ArtifactPath) { + cleanupErr = errors.Join(cleanupErr, m.cleanupInstallationFiles(local)) + } + return cleanupErr +} + +func (m *PluginManager) GetConfig(ctx context.Context, id int64) (json.RawMessage, error) { + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return m.decryptConfig(installation) +} + +func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMessage) (json.RawMessage, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + if len(raw) == 0 || len(raw) > pluginConfigMaxBytes || !json.Valid(raw) { + return nil, errors.New("插件配置必须是有效且大小受限的 JSON") + } + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + previousConfig, err := m.decryptConfig(installation) + if err != nil { + return nil, err + } + var normalized any + if err := json.Unmarshal(raw, &normalized); err != nil { + return nil, err + } + if normalized == nil { + return nil, errors.New("插件配置 JSON 根节点必须是对象") + } + if _, ok := normalized.(map[string]any); !ok { + return nil, errors.New("插件配置 JSON 根节点必须是对象") + } + canonical, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + temporary := false + if runtime == nil { + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + runtime, err = m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + temporary = true + defer runtime.kill() + } + if runtime != nil { + applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + canonical, err = runtime.validateAndApplyNormalizedConfig(applyCtx, canonical) + cancel() + if err != nil { + return nil, err + } + } + encrypted, err := m.encryptor.Encrypt(string(canonical)) + if err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) + } + return nil, fmt.Errorf("加密插件配置: %w", err) + } + if err := m.repo.UpdateConfig(ctx, id, encrypted, installation.BinarySHA256); err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) + } + return nil, err + } + if !temporary { + runtime.installation.ConfigEncrypted = encrypted + } + return canonical, nil +} + +func (m *PluginManager) restoreRuntimeConfig(id int64, runtime *pluginRuntime, previous json.RawMessage) error { + if runtime == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(rollbackCtx, previous); err != nil { + route := m.route.Load() + if route != nil && route.pluginID == id && route.runtime == runtime { + stateErr := m.markRuntimeUnavailable(route, "插件配置回滚失败: "+err.Error()) + return errors.Join(err, stateErr) + } + return err + } + return nil +} + +func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfigResponse, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + configJSON, err := m.decryptConfig(installation) + if err != nil { + return nil, err + } + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + temporary := false + if runtime == nil { + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + runtime, err = m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + temporary = true + } + if temporary { + defer runtime.kill() + } + testCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(testCtx, configJSON); err != nil { + return nil, err + } + return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON}) +} + +type pluginUIAssetClaims struct { + Version int `json:"version"` + PluginID int64 `json:"plugin_id"` + Expires int64 `json:"expires"` +} + +// CreateUIAssetToken 创建可跨实例校验的短时能力令牌,令牌不包含管理员凭据。 +func (m *PluginManager) CreateUIAssetToken(ctx context.Context, id int64, ttl time.Duration) (string, time.Time, error) { + if ttl <= 0 || ttl > time.Hour { + return "", time.Time{}, errors.New("插件 UI 会话有效期无效") + } + if _, err := m.repo.GetByID(ctx, id); err != nil { + return "", time.Time{}, err + } + expires := time.Now().Add(ttl) + raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix()}) + if err != nil { + return "", time.Time{}, err + } + // 加用途前缀,避免复用同一 AES-GCM 密钥的其他密文被当作 UI 能力令牌。 + encrypted, err := m.encryptor.Encrypt(pluginUITokenPrefix + string(raw)) + if err != nil { + return "", time.Time{}, fmt.Errorf("加密插件 UI 会话: %w", err) + } + return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), expires, nil +} + +func (m *PluginManager) ResolveUIAssetToken(token string) (int64, error) { + if len(token) == 0 || len(token) > 4096 { + return 0, errors.New("插件 UI 会话无效") + } + encrypted, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return 0, errors.New("插件 UI 会话无效") + } + plaintext, err := m.encryptor.Decrypt(string(encrypted)) + if err != nil { + return 0, errors.New("插件 UI 会话无效") + } + plaintext, ok := strings.CutPrefix(plaintext, pluginUITokenPrefix) + if !ok { + return 0, errors.New("插件 UI 会话无效") + } + var claims pluginUIAssetClaims + decoder := json.NewDecoder(strings.NewReader(plaintext)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&claims); err != nil || claims.Version != 1 || claims.PluginID <= 0 { + return 0, errors.New("插件 UI 会话无效") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return 0, errors.New("插件 UI 会话无效") + } + now := time.Now().Unix() + if now >= claims.Expires { + return 0, errors.New("插件 UI 会话已过期") + } + if claims.Expires > now+int64(time.Hour/time.Second) { + return 0, errors.New("插件 UI 会话无效") + } + return claims.PluginID, nil +} + +func (m *PluginManager) ReadUIAsset(ctx context.Context, id int64, relative string) ([]byte, string, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, "", err + } + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, "", err + } + path := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/") + if path == "" || path == "index.html" { + path = installation.Manifest.UI.Entrypoint + } else { + path = "ui/" + path + } + if _, declared := installation.Manifest.Files[path]; !declared || !strings.HasPrefix(path, "ui/") { + return nil, "", os.ErrNotExist + } + fullPath, err := safePluginJoin(installation.InstallPath, path) + if err != nil { + return nil, "", err + } + file, err := os.Open(fullPath) + if err != nil { + return nil, "", err + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, pluginUIAssetMaxBytes+1)) + if err != nil { + return nil, "", err + } + if len(data) > pluginUIAssetMaxBytes { + return nil, "", errors.New("插件 UI 资源超过大小限制") + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != installation.Manifest.Files[path] { + return nil, "", errors.New("插件 UI 资源哈希不匹配") + } + return data, path, nil +} + +func (m *PluginManager) RoundTripOpenAIOAuth(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, bool, error) { + if !m.ShouldRouteOpenAIOAuth(account) { + return nil, false, nil + } + route := m.route.Load() + if route == nil { + return nil, false, nil + } + if route.runtime == nil { + return nil, true, fmt.Errorf("OpenAI OAuth 插件不可用: %s", route.unavailable) + } + if route.runtime.client.Exited() { + runtimeErr := errors.New("OpenAI OAuth 插件进程已退出") + if stateErr := m.markRuntimeUnavailable(route, runtimeErr.Error()); stateErr != nil { + return nil, true, errors.Join(runtimeErr, stateErr) + } + return nil, true, runtimeErr + } + if !route.runtime.beginRequest() { + return nil, true, errors.New("OpenAI OAuth 插件正在停止") + } + response, err := route.runtime.roundTrip(ctx, request, proxyURL, account) + if err != nil { + route.runtime.finishRequest() + if route.runtime.client.Exited() { + if stateErr := m.markRuntimeUnavailable(route, err.Error()); stateErr != nil { + err = errors.Join(err, stateErr) + } + } + return nil, true, err + } + return response, true, nil +} + +// ShouldRouteOpenAIOAuth 判断该账号是否命中当前 OpenAI OAuth 插件绑定。 +// WebSocket 入口用它把命中的账号切换到 HTTP Bridge,避免绕过 v1 HTTP 插件协议。 +func (m *PluginManager) ShouldRouteOpenAIOAuth(account *Account) bool { + if m == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return false + } + route := m.route.Load() + return route != nil && route.rolloutPercent > 0 && int(stablePluginBucket(account.ID)) < route.rolloutPercent +} + +func (m *PluginManager) markRuntimeUnavailable(failedRoute *pluginRoute, message string) error { + m.mu.Lock() + current := m.route.Load() + if current != failedRoute { + m.mu.Unlock() + return nil + } + if failedRoute.runtime != nil { + failedRoute.runtime.kill() + } + delete(m.runtimes, failedRoute.pluginID) + m.route.Store(&pluginRoute{ + pluginID: failedRoute.pluginID, + rolloutPercent: failedRoute.rolloutPercent, + unavailable: message, + }) + m.mu.Unlock() + return nil +} + +func (m *PluginManager) prepareRuntime(ctx context.Context, installation *PluginInstallation, validateConfig bool) (*pluginRuntime, error) { + runtime, err := m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + configJSON, err := m.decryptConfig(installation) + if err == nil && validateConfig { + applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + err = runtime.validateAndApplyConfig(applyCtx, configJSON) + cancel() + } + if err != nil { + runtime.kill() + return nil, err + } + return runtime, nil +} + +func (m *PluginManager) publishRuntimeLocked(installation *PluginInstallation, runtime *pluginRuntime) { + if old := m.runtimes[installation.ID]; old != nil { + old.kill() + } + m.runtimes[installation.ID] = runtime + m.route.Store(&pluginRoute{ + pluginID: installation.ID, + runtime: runtime, + rolloutPercent: bindingRollout(installation.Bindings), + }) +} + +func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInstallation) (*pluginRuntime, error) { + socketDir := filepath.Join(m.installer.RootDir(), "runtime") + if err := os.MkdirAll(socketDir, 0o700); err != nil { + return nil, err + } + timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second + return startPluginRuntime(ctx, installation, timeout, socketDir) +} + +func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime { + runtime := m.runtimes[id] + delete(m.runtimes, id) + if route := m.route.Load(); route != nil && route.pluginID == id { + m.route.Store(nil) + } + return runtime +} + +func (m *PluginManager) decryptConfig(installation *PluginInstallation) (json.RawMessage, error) { + if installation == nil || strings.TrimSpace(installation.ConfigEncrypted) == "" { + return json.RawMessage(`{}`), nil + } + plaintext, err := m.encryptor.Decrypt(installation.ConfigEncrypted) + if err != nil { + return nil, fmt.Errorf("解密插件配置: %w", err) + } + if !json.Valid([]byte(plaintext)) { + return nil, errors.New("已保存的插件配置不是有效 JSON") + } + return json.RawMessage(plaintext), nil +} + +func (m *PluginManager) removeManagedPath(target string) error { + if strings.TrimSpace(target) == "" { + return nil + } + root, err := filepath.Abs(m.installer.RootDir()) + if err != nil { + return err + } + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + relative, err := filepath.Rel(root, absTarget) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("拒绝删除插件根目录之外的路径") + } + return os.RemoveAll(absTarget) +} + +func hasEnabledOpenAIBinding(bindings []PluginBinding) bool { + for _, binding := range bindings { + if binding.Enabled && binding.Capability == PluginCapabilityOpenAIOAuthOutbound && + binding.Platform == PlatformOpenAI && binding.AccountType == AccountTypeOAuth { + return true + } + } + return false +} + +func bindingRollout(bindings []PluginBinding) int { + for _, binding := range bindings { + if binding.Capability == PluginCapabilityOpenAIOAuthOutbound { + return binding.RolloutPercent + } + } + return 100 +} + +func stablePluginBucket(accountID int64) uint64 { + value := uint64(accountID) + value ^= value >> 33 + value *= 0xff51afd7ed558ccd + value ^= value >> 33 + return value % 100 +} diff --git a/backend/internal/service/plugin_manager_routing_test.go b/backend/internal/service/plugin_manager_routing_test.go new file mode 100644 index 0000000000..b2f4654cfe --- /dev/null +++ b/backend/internal/service/plugin_manager_routing_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type pluginRoutingHTTPUpstream struct { + doCalls int + doWithTLSCalls int +} + +func (u *pluginRoutingHTTPUpstream) Do(*http.Request, string, int64, int) (*http.Response, error) { + u.doCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("legacy")), + }, nil +} + +func (u *pluginRoutingHTTPUpstream) DoWithTLS( + request *http.Request, + proxyURL string, + accountID int64, + accountConcurrency int, + _ *tlsfingerprint.Profile, +) (*http.Response, error) { + u.doWithTLSCalls++ + return u.Do(request, proxyURL, accountID, accountConcurrency) +} + +func TestPluginManagerRoutingDoesNotTouchAPIKeyOrOtherProviders(t *testing.T) { + manager := &PluginManager{} + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + + accounts := []*Account{ + {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + {ID: 2, Platform: PlatformAnthropic, Type: AccountTypeOAuth}, + {ID: 3, Platform: PlatformGemini, Type: AccountTypeOAuth}, + } + for _, account := range accounts { + response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account) + assert.Nil(t, response) + assert.False(t, handled) + assert.NoError(t, routeErr) + } +} + +func TestPluginManagerRoutingKeepsOAuthOnLegacyPathWithoutEnabledBinding(t *testing.T) { + manager := &PluginManager{} + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account) + + assert.Nil(t, response) + assert.False(t, handled) + assert.NoError(t, routeErr) +} + +func TestPluginManagerRoutingSelectsOnlyEligibleOpenAIOAuthAccounts(t *testing.T) { + manager := &PluginManager{} + manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"}) + + assert.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) + assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})) + assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth})) + assert.False(t, manager.ShouldRouteOpenAIOAuth(nil)) +} + +func TestOpenAIGatewayPluginRoutingPreservesAPIKeyAndFailsClosedForOAuth(t *testing.T) { + manager := &PluginManager{} + manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"}) + upstream := &pluginRoutingHTTPUpstream{} + service := &OpenAIGatewayService{pluginManager: manager, httpUpstream: upstream} + + apiKeyRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + apiKeyResponse, err := service.doOpenAIUpstream(apiKeyRequest, "", &Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, + }) + require.NoError(t, err) + require.NotNil(t, apiKeyResponse) + _ = apiKeyResponse.Body.Close() + assert.Equal(t, 1, upstream.doCalls) + + oauthRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + oauthResponse, err := service.doOpenAIUpstream(oauthRequest, "", &Account{ + ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + }) + require.Error(t, err) + assert.Nil(t, oauthResponse) + assert.Contains(t, err.Error(), "插件不可用") + assert.Equal(t, 1, upstream.doCalls) +} + +func TestStablePluginBucketIsDeterministicAndBounded(t *testing.T) { + for id := int64(1); id <= 1000; id++ { + first := stablePluginBucket(id) + assert.Equal(t, first, stablePluginBucket(id)) + assert.Less(t, first, uint64(100)) + } +} diff --git a/backend/internal/service/plugin_manifest.go b/backend/internal/service/plugin_manifest.go new file mode 100644 index 0000000000..b06e3d352d --- /dev/null +++ b/backend/internal/service/plugin_manifest.go @@ -0,0 +1,214 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "runtime" + "sort" + "strings" + "time" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" +) + +const ( + PluginCapabilityOpenAIOAuthOutbound = "openai.oauth.outbound_transport.v1" + PluginStateDisabled = "disabled" + PluginStateStarting = "starting" + PluginStateEnabled = "enabled" + PluginStateError = "error" + PluginStateIncompatible = "incompatible" + PluginSignatureTrusted = "trusted" + PluginSignatureUnsigned = "unsigned" +) + +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) + +var ErrPluginStateChanged = errors.New("插件状态已在其他实例中变化,请刷新后重试") + +// PluginManifest 是 .s2plugin 包中可在执行二进制前检查的声明。 +type PluginManifest struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Requires PluginRequirements `json:"requires"` + Capabilities []PluginCapability `json:"capabilities"` + Runtimes map[string]PluginRuntime `json:"runtimes"` + UI PluginUIManifest `json:"ui"` + Files map[string]string `json:"files"` +} + +type PluginRequirements struct { + Sub2API string `json:"sub2api"` + RecommendedSub2APIVersion string `json:"recommended_sub2api_version,omitempty"` + TestedSub2APIVersions []string `json:"tested_sub2api_versions,omitempty"` + PluginProtocol int `json:"plugin_protocol"` + TransportAPI int `json:"transport_api"` + UIBridge int `json:"ui_bridge"` +} + +type PluginCapability struct { + ID string `json:"id"` + Platform string `json:"platform"` + AccountType string `json:"account_type"` +} + +type PluginRuntime struct { + Path string `json:"path"` +} + +type PluginUIManifest struct { + Entrypoint string `json:"entrypoint"` +} + +type PluginSignature struct { + Algorithm string `json:"algorithm"` + KeyID string `json:"key_id"` + Signature string `json:"signature"` +} + +// PluginCompatibility 是管理页面展示和启用门禁共同使用的兼容性结论。 +type PluginCompatibility struct { + Compatible bool `json:"compatible"` + Tested bool `json:"tested"` + Status string `json:"status"` + Message string `json:"message"` + CurrentSub2API string `json:"current_sub2api_version"` + RequiredSub2API string `json:"required_sub2api_version"` + RecommendedSub2API string `json:"recommended_sub2api_version"` + PluginProtocol int `json:"plugin_protocol"` + TransportAPI int `json:"transport_api"` + UIBridge int `json:"ui_bridge"` +} + +type PluginInstallation struct { + ID int64 `json:"id"` + PluginKey string `json:"plugin_key"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Manifest PluginManifest `json:"manifest"` + ArtifactData []byte `json:"-"` + ArtifactPath string `json:"-"` + InstallPath string `json:"-"` + BinaryPath string `json:"-"` + BinarySHA256 string `json:"binary_sha256"` + SignatureStatus string `json:"signature_status"` + State string `json:"state"` + ConfigEncrypted string `json:"-"` + LastError string `json:"last_error"` + InstalledBy *int64 `json:"installed_by"` + InstalledAt time.Time `json:"installed_at"` + EnabledAt *time.Time `json:"enabled_at"` + UpdatedAt time.Time `json:"updated_at"` + Bindings []PluginBinding `json:"bindings"` + Compatibility PluginCompatibility `json:"compatibility"` + RuntimeHealthy bool `json:"runtime_healthy"` + RuntimeMessage string `json:"runtime_message"` +} + +type PluginBinding struct { + ID int64 `json:"id"` + PluginID int64 `json:"plugin_id"` + Capability string `json:"capability"` + Platform string `json:"platform"` + AccountType string `json:"account_type"` + Enabled bool `json:"enabled"` + RolloutPercent int `json:"rollout_percent"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PluginRepository interface { + List(ctx context.Context) ([]*PluginInstallation, error) + GetByID(ctx context.Context, id int64) (*PluginInstallation, error) + GetByKey(ctx context.Context, key string) (*PluginInstallation, error) + Install(ctx context.Context, plugin *PluginInstallation, bindings []PluginBinding) (*PluginInstallation, error) + GetArtifact(ctx context.Context, id int64) ([]byte, error) + Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error + BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error + MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error + UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error + UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error + UpdateBindingsAndState(ctx context.Context, pluginID int64, bindings []PluginBinding, state, lastError string, enabledAt *time.Time, expectedState, expectedBinarySHA256 string) error +} + +func (m PluginManifest) RuntimeKey() string { + return runtime.GOOS + "-" + runtime.GOARCH +} + +func (m PluginManifest) Validate() error { + if m.SchemaVersion != 1 { + return fmt.Errorf("不支持的插件清单版本: %d", m.SchemaVersion) + } + if !pluginIDPattern.MatchString(m.ID) || len(m.ID) > 160 { + return errors.New("插件 ID 必须是长度不超过 160 的小写命名空间标识") + } + if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 { + return errors.New("插件名称不能为空且不能超过 160 个字符") + } + if normalizeSemver(m.Version) == "" { + return errors.New("插件版本必须是有效的语义化版本") + } + if strings.TrimSpace(m.Requires.Sub2API) == "" { + return errors.New("插件必须声明 requires.sub2api") + } + if m.Requires.PluginProtocol != pluginv1.ProtocolVersion || + m.Requires.TransportAPI != pluginv1.TransportAPIVersion || + m.Requires.UIBridge != pluginv1.UIBridgeVersion { + return errors.New("插件协议、传输 API 或 UI Bridge 版本与当前宿主不兼容") + } + if len(m.Capabilities) == 0 { + return errors.New("插件必须声明至少一个能力") + } + for _, capability := range m.Capabilities { + if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth { + return fmt.Errorf("初期仅支持能力 %s", PluginCapabilityOpenAIOAuthOutbound) + } + } + runtimeEntry, ok := m.Runtimes[m.RuntimeKey()] + if !ok || !safePluginRelativePath(runtimeEntry.Path) { + return fmt.Errorf("插件不支持当前运行平台 %s", m.RuntimeKey()) + } + if !safePluginRelativePath(m.UI.Entrypoint) || !strings.HasPrefix(m.UI.Entrypoint, "ui/") { + return errors.New("插件 UI 入口必须位于 ui/ 目录") + } + if len(m.Files) == 0 { + return errors.New("插件清单必须声明文件哈希") + } + for path, hash := range m.Files { + if !safePluginRelativePath(path) || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(hash) { + return fmt.Errorf("插件文件声明无效: %s", path) + } + } + if _, ok := m.Files[runtimeEntry.Path]; !ok { + return errors.New("运行时二进制未包含在文件哈希声明中") + } + if _, ok := m.Files[m.UI.Entrypoint]; !ok { + return errors.New("UI 入口未包含在文件哈希声明中") + } + return nil +} + +func safePluginRelativePath(path string) bool { + cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") + return cleaned != "" && cleaned != "." && !strings.HasPrefix(cleaned, "/") && + !strings.HasPrefix(cleaned, "../") && !strings.Contains(cleaned, "/../") && cleaned == strings.TrimPrefix(cleaned, "./") +} + +func (m PluginManifest) MarshalJSONBytes() ([]byte, error) { + return json.Marshal(m) +} + +func (m PluginManifest) SortedCapabilities() []PluginCapability { + out := append([]PluginCapability(nil), m.Capabilities...) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go new file mode 100644 index 0000000000..2ef9130b0c --- /dev/null +++ b/backend/internal/service/plugin_package.go @@ -0,0 +1,387 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +const ( + pluginManifestFilename = "manifest.json" + pluginSignatureFilename = "signature.json" + pluginArchiveMaxFiles = 512 + builtInOpenAITransportPluginID = "local.sub2api.openai-transport" + builtInOpenAITransportPublisherKeyID = "sub2api-openai-transport-v1" + builtInOpenAITransportPublisherKeyBase64 = "MqzSXAoG0iVR5kKWrC+mqcCeExkrT6zAr2WpQ4sA+yc=" +) + +type PluginPackageInstaller struct { + cfg *config.Config + hostInfo PluginHostInfo + rootDir string +} + +func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller { + return &PluginPackageInstaller{ + cfg: cfg, + hostInfo: hostInfo, + rootDir: resolvePluginRootDir(cfg), + } +} + +func resolvePluginRootDir(cfg *config.Config) string { + if cfg != nil && strings.TrimSpace(cfg.Plugins.DataDir) != "" { + return filepath.Clean(cfg.Plugins.DataDir) + } + base := strings.TrimSpace(os.Getenv("DATA_DIR")) + if base == "" { + base = "./data" + } + return filepath.Join(base, "plugins") +} + +func (i *PluginPackageInstaller) RootDir() string { + return i.rootDir +} + +func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) { + if i == nil || i.cfg == nil { + return nil, errors.New("插件安装器未配置") + } + if err := ctx.Err(); err != nil { + return nil, err + } + stagingDir := filepath.Join(i.rootDir, "staging") + packagesDir := filepath.Join(i.rootDir, "packages") + installedDir := filepath.Join(i.rootDir, "installed") + for _, dir := range []string{stagingDir, packagesDir, installedDir} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("创建插件目录: %w", err) + } + } + + tempFile, err := os.CreateTemp(stagingDir, "upload-*.s2plugin") + if err != nil { + return nil, fmt.Errorf("创建插件上传临时文件: %w", err) + } + tempPath := tempFile.Name() + committed := false + defer func() { + _ = tempFile.Close() + if !committed { + _ = os.Remove(tempPath) + } + }() + + hasher := sha256.New() + limit := i.cfg.Plugins.MaxUploadBytes + written, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(reader, limit+1)) + if err != nil { + return nil, fmt.Errorf("读取插件包: %w", err) + } + if written > limit { + return nil, fmt.Errorf("插件包超过 %d 字节限制", limit) + } + if err := tempFile.Sync(); err != nil { + return nil, fmt.Errorf("同步插件包: %w", err) + } + if err := tempFile.Close(); err != nil { + return nil, fmt.Errorf("关闭插件包: %w", err) + } + artifactSHA := hex.EncodeToString(hasher.Sum(nil)) + + archive, err := zip.OpenReader(tempPath) + if err != nil { + return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err) + } + defer func() { _ = archive.Close() }() + manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader) + if err != nil { + return nil, err + } + compatibility := EvaluatePluginCompatibility(manifest, i.hostInfo) + initialState := PluginStateDisabled + if !compatibility.Compatible { + initialState = PluginStateIncompatible + } + + installParent := filepath.Join(installedDir, manifest.ID) + if err := os.MkdirAll(installParent, 0o700); err != nil { + return nil, fmt.Errorf("创建插件安装父目录: %w", err) + } + extractPath, err := os.MkdirTemp(installParent, ".install-*") + if err != nil { + return nil, fmt.Errorf("创建插件安装临时目录: %w", err) + } + installNonce := strings.TrimPrefix(filepath.Base(extractPath), ".install-") + installPath := filepath.Join(installParent, manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce) + extracted := false + defer func() { + if !extracted { + _ = os.RemoveAll(extractPath) + } + }() + if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil { + return nil, err + } + if err := os.Rename(extractPath, installPath); err != nil { + return nil, fmt.Errorf("提交插件安装目录: %w", err) + } + extracted = true + + artifactPath := filepath.Join(packagesDir, manifest.ID+"-"+manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce+".s2plugin") + if err := os.Rename(tempPath, artifactPath); err != nil { + _ = os.RemoveAll(installPath) + return nil, fmt.Errorf("保存插件包: %w", err) + } + committed = true + artifactData, err := os.ReadFile(artifactPath) + if err != nil { + _ = os.Remove(artifactPath) + _ = os.RemoveAll(installPath) + return nil, fmt.Errorf("读取已保存插件包: %w", err) + } + runtimeEntry := manifest.Runtimes[manifest.RuntimeKey()] + return &PluginInstallation{ + PluginKey: manifest.ID, + Name: manifest.Name, + Version: manifest.Version, + Description: manifest.Description, + Author: manifest.Author, + Manifest: manifest, + ArtifactData: artifactData, + ArtifactPath: artifactPath, + InstallPath: installPath, + BinaryPath: filepath.Join(installPath, filepath.FromSlash(runtimeEntry.Path)), + BinarySHA256: manifest.Files[runtimeEntry.Path], + SignatureStatus: signatureStatus, + State: initialState, + InstalledBy: installedBy, + Compatibility: compatibility, + }, nil +} + +func (i *PluginPackageInstaller) inspectArchive(archive *zip.Reader) (PluginManifest, []byte, string, error) { + if len(archive.File) == 0 || len(archive.File) > pluginArchiveMaxFiles { + return PluginManifest{}, nil, "", errors.New("插件包文件数量无效") + } + entries := make(map[string]*zip.File, len(archive.File)) + var total uint64 + for _, file := range archive.File { + if file.FileInfo().IsDir() { + if _, err := normalizePluginArchivePath(strings.TrimSuffix(file.Name, "/")); err != nil { + return PluginManifest{}, nil, "", err + } + continue + } + name, err := normalizePluginArchivePath(file.Name) + if err != nil { + return PluginManifest{}, nil, "", err + } + if _, exists := entries[name]; exists { + return PluginManifest{}, nil, "", fmt.Errorf("插件包包含重复路径: %s", name) + } + if file.Mode()&os.ModeSymlink != 0 { + return PluginManifest{}, nil, "", fmt.Errorf("插件包不允许符号链接: %s", name) + } + total += file.UncompressedSize64 + if total > uint64(i.cfg.Plugins.MaxUncompressedBytes) { + return PluginManifest{}, nil, "", errors.New("插件包解压后体积超过限制") + } + entries[name] = file + } + manifestFile := entries[pluginManifestFilename] + if manifestFile == nil { + return PluginManifest{}, nil, "", errors.New("插件包缺少 manifest.json") + } + manifestRaw, err := readPluginZipFile(manifestFile, 2*1024*1024) + if err != nil { + return PluginManifest{}, nil, "", fmt.Errorf("读取插件清单: %w", err) + } + var manifest PluginManifest + decoder := json.NewDecoder(bytes.NewReader(manifestRaw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return PluginManifest{}, nil, "", fmt.Errorf("解析插件清单: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return PluginManifest{}, nil, "", errors.New("插件清单只能包含一个 JSON 对象") + } + if err := manifest.Validate(); err != nil { + return PluginManifest{}, nil, "", err + } + for path := range entries { + if path == pluginManifestFilename || path == pluginSignatureFilename { + continue + } + if _, declared := manifest.Files[path]; !declared { + return PluginManifest{}, nil, "", fmt.Errorf("插件包包含未声明文件: %s", path) + } + } + for path := range manifest.Files { + if entries[path] == nil { + return PluginManifest{}, nil, "", fmt.Errorf("插件包缺少已声明文件: %s", path) + } + } + signatureStatus, err := i.verifySignature(entries[pluginSignatureFilename], manifestRaw, manifest.ID) + if err != nil { + return PluginManifest{}, nil, "", err + } + return manifest, manifestRaw, signatureStatus, nil +} + +func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []byte, pluginID string) (string, error) { + if file == nil { + if i.cfg.Plugins.AllowUnsigned { + return PluginSignatureUnsigned, nil + } + return "", errors.New("生产配置不允许安装未签名插件") + } + raw, err := readPluginZipFile(file, 64*1024) + if err != nil { + return "", fmt.Errorf("读取插件签名: %w", err) + } + var signature PluginSignature + if err := json.Unmarshal(raw, &signature); err != nil { + return "", fmt.Errorf("解析插件签名: %w", err) + } + if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" { + return "", errors.New("插件签名算法或密钥 ID 无效") + } + encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID) + if encodedKey == "" { + return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID) + } + publicKey, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return "", fmt.Errorf("受信任发布者密钥无效: %s", signature.KeyID) + } + signatureBytes, err := base64.StdEncoding.DecodeString(signature.Signature) + if err != nil || !ed25519.Verify(ed25519.PublicKey(publicKey), manifestRaw, signatureBytes) { + return "", errors.New("插件签名校验失败") + } + return PluginSignatureTrusted, nil +} + +func trustedPluginPublisherKey(cfg *config.Config, keyID, pluginID string) string { + // 内置公钥是官方私有插件的固定信任根,不允许被部署配置覆盖。 + if keyID == builtInOpenAITransportPublisherKeyID { + if pluginID != builtInOpenAITransportPluginID { + return "" + } + return builtInOpenAITransportPublisherKeyBase64 + } + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.Plugins.TrustedPublishers[keyID]) +} + +func (i *PluginPackageInstaller) extractArchive(ctx context.Context, archive *zip.Reader, manifest PluginManifest, target string) error { + var extractedBytes int64 + extractLimit := i.cfg.Plugins.MaxUncompressedBytes + for path, expectedHash := range manifest.Files { + if err := ctx.Err(); err != nil { + return err + } + var source *zip.File + for _, file := range archive.File { + if strings.ReplaceAll(file.Name, "\\", "/") == path { + source = file + break + } + } + if source == nil { + return fmt.Errorf("缺少插件文件: %s", path) + } + destination, err := safePluginJoin(target, path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return fmt.Errorf("创建插件文件目录: %w", err) + } + input, err := source.Open() + if err != nil { + return fmt.Errorf("打开插件文件 %s: %w", path, err) + } + hasher := sha256.New() + mode := os.FileMode(0o600) + if path == manifest.Runtimes[manifest.RuntimeKey()].Path { + mode = 0o700 + } + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + _ = input.Close() + return fmt.Errorf("创建插件文件 %s: %w", path, err) + } + remaining := extractLimit - extractedBytes + if remaining < 0 { + remaining = 0 + } + copied, copyErr := io.Copy(io.MultiWriter(output, hasher), io.LimitReader(input, remaining+1)) + extractedBytes += copied + closeOutErr := output.Close() + closeInErr := input.Close() + if copyErr != nil || closeOutErr != nil || closeInErr != nil { + return fmt.Errorf("解压插件文件 %s 失败", path) + } + if extractedBytes > extractLimit { + return errors.New("插件包实际解压体积超过限制") + } + if actual := hex.EncodeToString(hasher.Sum(nil)); actual != expectedHash { + return fmt.Errorf("插件文件哈希不匹配: %s", path) + } + } + return nil +} + +func normalizePluginArchivePath(name string) (string, error) { + normalized := strings.ReplaceAll(strings.TrimSpace(name), "\\", "/") + if normalized == "" || strings.HasPrefix(normalized, "/") || strings.Contains(normalized, "\x00") { + return "", errors.New("插件包包含无效路径") + } + cleaned := filepath.ToSlash(filepath.Clean(normalized)) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || cleaned != normalized { + return "", fmt.Errorf("插件包包含不安全路径: %s", name) + } + return cleaned, nil +} + +func safePluginJoin(root, relative string) (string, error) { + destination := filepath.Join(root, filepath.FromSlash(relative)) + rel, err := filepath.Rel(root, destination) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("插件路径越界: %s", relative) + } + return destination, nil +} + +func readPluginZipFile(file *zip.File, limit int64) ([]byte, error) { + reader, err := file.Open() + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("插件文件超过读取限制") + } + return data, nil +} diff --git a/backend/internal/service/plugin_package_test.go b/backend/internal/service/plugin_package_test.go new file mode 100644 index 0000000000..cd0e42de1a --- /dev/null +++ b/backend/internal/service/plugin_package_test.go @@ -0,0 +1,245 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"}) + archive := buildTestPluginArchive(t, nil, "") + + installation, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + + require.NoError(t, err) + assert.Equal(t, PluginStateDisabled, installation.State) + assert.Equal(t, PluginSignatureUnsigned, installation.SignatureStatus) + assert.FileExists(t, installation.ArtifactPath) + info, statErr := os.Stat(installation.BinaryPath) + require.NoError(t, statErr) + assert.NotZero(t, info.Mode()&0o100) + assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport")) +} + +func TestPluginPackageInstallerAllowsRepeatedIdenticalUpload(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + archive := buildTestPluginArchive(t, nil, "") + + first, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + require.NoError(t, err) + second, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + require.NoError(t, err) + + assert.NotEqual(t, first.InstallPath, second.InstallPath) + assert.NotEqual(t, first.ArtifactPath, second.ArtifactPath) + assert.FileExists(t, first.BinaryPath) + assert.FileExists(t, second.BinaryPath) +} + +func TestPluginPackageInstallerRejectsUnsignedPackageByDefault(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), false) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + + _, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "不允许安装未签名插件") +} + +func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + cfg := testPluginConfig(t.TempDir(), false) + cfg.Plugins.TrustedPublishers["local-test"] = base64.StdEncoding.EncodeToString(publicKey) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + + installation, installErr := installer.Install( + context.Background(), + bytes.NewReader(buildTestPluginArchive(t, privateKey, "local-test")), + nil, + ) + + require.NoError(t, installErr) + assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus) +} + +func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), false) + cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥" + + encodedKey := trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, builtInOpenAITransportPluginID) + publicKey, err := base64.StdEncoding.DecodeString(encodedKey) + + require.NoError(t, err) + assert.Len(t, publicKey, ed25519.PublicKeySize) + assert.Equal(t, builtInOpenAITransportPublisherKeyBase64, encodedKey) + assert.Empty(t, trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, "com.example.other-plugin")) +} + +func TestPluginPackageInstallerRejectsPathTraversal(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + archive := buildTestPluginArchiveWithExtra(t, nil, "", "../escape", []byte("escape")) + + _, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "不安全路径") +} + +func TestPluginPackageInstallerKeepsHostVersionMismatchDisabled(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.0"}) + + installation, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) + + require.NoError(t, err) + assert.Equal(t, PluginStateIncompatible, installation.State) + assert.False(t, installation.Compatibility.Compatible) +} + +func TestPluginPackageInstallerRejectsHashMismatch(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + manifest := testPluginManifest(map[string][]byte{ + "bin/plugin": []byte("binary"), + "ui/index.html": []byte(""), + }) + manifest.Files["bin/plugin"] = string(bytes.Repeat([]byte("0"), 64)) + + _, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "哈希不匹配") +} + +func TestPluginPackageInstallerEnforcesActualExtractionLimit(t *testing.T) { + archiveData := buildTestPluginArchive(t, nil, "") + archive, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + require.NoError(t, err) + cfg := testPluginConfig(t.TempDir(), true) + cfg.Plugins.MaxUncompressedBytes = 8 + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + + err = installer.extractArchive(context.Background(), archive, testPluginManifest(nil), t.TempDir()) + + require.ErrorContains(t, err, "实际解压体积超过限制") +} + +func testPluginConfig(root string, allowUnsigned bool) *config.Config { + return &config.Config{Plugins: config.PluginConfig{ + DataDir: root, + AllowUnsigned: allowUnsigned, + TrustedPublishers: map[string]string{}, + MaxUploadBytes: 64 * 1024 * 1024, + MaxUncompressedBytes: 128 * 1024 * 1024, + StartTimeoutSeconds: 5, + }} +} + +func testPluginManifest(files map[string][]byte) PluginManifest { + if files == nil { + files = map[string][]byte{ + "bin/plugin": []byte("binary"), + "ui/index.html": []byte(""), + } + } + hashes := make(map[string]string, len(files)) + for path, data := range files { + digest := sha256.Sum256(data) + hashes[path] = hex.EncodeToString(digest[:]) + } + return PluginManifest{ + SchemaVersion: 1, + ID: "com.example.openai-transport", + Name: "测试 OpenAI Transport", + Version: "0.1.0", + Requires: PluginRequirements{ + Sub2API: ">=0.1.170 <0.2.0", + RecommendedSub2APIVersion: "0.1.179", + TestedSub2APIVersions: []string{"0.1.179"}, + PluginProtocol: pluginv1.ProtocolVersion, + TransportAPI: pluginv1.TransportAPIVersion, + UIBridge: pluginv1.UIBridgeVersion, + }, + Capabilities: []PluginCapability{{ + ID: PluginCapabilityOpenAIOAuthOutbound, + Platform: PlatformOpenAI, + AccountType: AccountTypeOAuth, + }}, + Runtimes: map[string]PluginRuntime{ + PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"}, + }, + UI: PluginUIManifest{Entrypoint: "ui/index.html"}, + Files: hashes, + } +} + +func buildTestPluginArchive(t *testing.T, privateKey ed25519.PrivateKey, keyID string) []byte { + return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, nil) +} + +func buildTestPluginArchiveWithExtra(t *testing.T, privateKey ed25519.PrivateKey, keyID, path string, data []byte) []byte { + return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, map[string][]byte{path: data}) +} + +func buildPluginArchive( + t *testing.T, + manifest PluginManifest, + privateKey ed25519.PrivateKey, + keyID string, + extra map[string][]byte, +) []byte { + t.Helper() + manifestRaw, err := json.Marshal(manifest) + require.NoError(t, err) + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + writeZipEntry(t, writer, pluginManifestFilename, manifestRaw) + for path := range manifest.Files { + data := []byte("binary") + if path == "ui/index.html" { + data = []byte("") + } + writeZipEntry(t, writer, path, data) + } + for path, data := range extra { + writeZipEntry(t, writer, path, data) + } + if len(privateKey) > 0 { + signatureRaw, marshalErr := json.Marshal(PluginSignature{ + Algorithm: "ed25519", + KeyID: keyID, + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestRaw)), + }) + require.NoError(t, marshalErr) + writeZipEntry(t, writer, pluginSignatureFilename, signatureRaw) + } + require.NoError(t, writer.Close()) + return buffer.Bytes() +} + +func writeZipEntry(t *testing.T, writer *zip.Writer, path string, data []byte) { + t.Helper() + entry, err := writer.Create(path) + require.NoError(t, err) + _, err = entry.Write(data) + require.NoError(t, err) +} diff --git a/backend/internal/service/plugin_runtime.go b/backend/internal/service/plugin_runtime.go new file mode 100644 index 0000000000..c742569443 --- /dev/null +++ b/backend/internal/service/plugin_runtime.go @@ -0,0 +1,418 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + hclog "github.com/hashicorp/go-hclog" + hcplugin "github.com/hashicorp/go-plugin" +) + +type pluginRuntime struct { + installation *PluginInstallation + client *hcplugin.Client + api pluginv1.TransportPluginClient + inFlight atomic.Int64 + draining atomic.Bool + done chan struct{} + doneOnce sync.Once +} + +func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) { + if installation == nil { + return nil, errors.New("插件安装记录为空") + } + checksum, err := hex.DecodeString(installation.BinarySHA256) + if err != nil || len(checksum) != sha256.Size { + return nil, errors.New("插件二进制哈希无效") + } + cmd := exec.CommandContext(context.WithoutCancel(ctx), installation.BinaryPath) + client := hcplugin.NewClient(&hcplugin.ClientConfig{ + HandshakeConfig: pluginv1.HandshakeConfig, + Plugins: pluginv1.ClientPluginMap(), + Cmd: cmd, + AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC}, + StartTimeout: startTimeout, + SecureConfig: &hcplugin.SecureConfig{ + Checksum: checksum, + Hash: sha256.New(), + }, + Logger: hclog.NewNullLogger(), + SyncStdout: io.Discard, + SyncStderr: io.Discard, + UnixSocketConfig: &hcplugin.UnixSocketConfig{TempDir: socketDir}, + SkipHostEnv: true, + }) + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return nil, fmt.Errorf("启动插件进程: %w", err) + } + dispensed, err := rpcClient.Dispense(pluginv1.TransportPluginName) + if err != nil { + client.Kill() + return nil, fmt.Errorf("获取插件传输能力: %w", err) + } + api, ok := dispensed.(pluginv1.TransportPluginClient) + if !ok { + client.Kill() + return nil, errors.New("插件未实现传输 gRPC 客户端") + } + runtime := &pluginRuntime{ + installation: installation, + client: client, + api: api, + done: make(chan struct{}), + } + infoCtx, cancel := context.WithTimeout(ctx, startTimeout) + defer cancel() + info, err := api.GetInfo(infoCtx, &pluginv1.GetInfoRequest{}) + if err != nil { + runtime.kill() + return nil, fmt.Errorf("读取插件信息: %w", err) + } + if info.PluginId != installation.PluginKey || info.PluginVersion != installation.Version || + info.ProtocolVersion != pluginv1.ProtocolVersion || info.TransportApiVersion != pluginv1.TransportAPIVersion { + runtime.kill() + return nil, errors.New("插件运行时信息与已校验清单不一致") + } + health, err := api.Health(infoCtx, &pluginv1.HealthRequest{}) + if err != nil || !health.Healthy { + runtime.kill() + if err != nil { + return nil, fmt.Errorf("插件健康检查失败: %w", err) + } + return nil, fmt.Errorf("插件不健康: %s", health.Message) + } + return runtime, nil +} + +func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error { + _, err := r.validateAndApplyNormalizedConfig(ctx, configJSON) + return err +} + +func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) { + validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON}) + if err != nil { + return nil, fmt.Errorf("插件配置校验失败: %w", err) + } + if !validation.Valid { + return nil, fmt.Errorf("插件配置无效: %s", validation.Message) + } + if len(validation.NormalizedConfigJson) > 0 { + configJSON = validation.NormalizedConfigJson + } + if len(configJSON) == 0 || len(configJSON) > pluginConfigMaxBytes || !json.Valid(configJSON) { + return nil, errors.New("插件返回的规范化配置不是有效且大小受限的 JSON") + } + var normalized any + if err := json.Unmarshal(configJSON, &normalized); err != nil { + return nil, fmt.Errorf("解析插件规范化配置: %w", err) + } + if normalized == nil { + return nil, errors.New("插件返回的规范化配置根节点必须是对象") + } + if _, ok := normalized.(map[string]any); !ok { + return nil, errors.New("插件返回的规范化配置根节点必须是对象") + } + configJSON, err = json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("序列化插件规范化配置: %w", err) + } + applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON}) + if err != nil { + return nil, fmt.Errorf("应用插件配置失败: %w", err) + } + if !applied.Applied { + return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message) + } + return configJSON, nil +} + +func (r *pluginRuntime) checkHealth(ctx context.Context) error { + if r == nil || r.api == nil || r.client == nil || r.client.Exited() { + return errors.New("插件进程已退出") + } + health, err := r.api.Health(ctx, &pluginv1.HealthRequest{}) + if err != nil { + return fmt.Errorf("插件健康检查失败: %w", err) + } + if health == nil || !health.Healthy { + message := "插件报告不健康" + if health != nil && strings.TrimSpace(health.Message) != "" { + message = "插件不健康: " + health.Message + } + return errors.New(message) + } + return nil +} + +func (r *pluginRuntime) beginRequest() bool { + if r == nil || r.draining.Load() { + return false + } + r.inFlight.Add(1) + if r.draining.Load() { + r.finishRequest() + return false + } + return true +} + +func (r *pluginRuntime) finishRequest() { + if r.inFlight.Add(-1) == 0 && r.draining.Load() { + r.doneOnce.Do(func() { close(r.done) }) + } +} + +func (r *pluginRuntime) drain(timeout time.Duration) { + if r == nil { + return + } + r.draining.Store(true) + if r.inFlight.Load() == 0 { + r.doneOnce.Do(func() { close(r.done) }) + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-r.done: + case <-timer.C: + } + r.kill() +} + +func (r *pluginRuntime) kill() { + if r != nil && r.client != nil { + r.client.Kill() + } +} + +func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) { + if request == nil || request.URL == nil || account == nil { + return nil, errors.New("插件出站请求参数不完整") + } + streamCtx, cancel := context.WithCancel(ctx) + stream, err := r.api.Forward(streamCtx) + if err != nil { + cancel() + return nil, normalizePluginRPCError(ctx, "创建插件转发流", err, false) + } + requestID := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(account.ID, 36) + if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_Start{Start: &pluginv1.ForwardRequestStart{ + RequestId: requestID, + Method: request.Method, + Url: request.URL.String(), + Host: request.Host, + Headers: headersToPlugin(request.Header), + ProxyUrl: proxyURL, + AccountId: account.ID, + AccountConcurrency: int32(account.Concurrency), + Platform: account.Platform, + AccountType: account.Type, + ContentLength: request.ContentLength, + HasBody: request.Body != nil && request.Body != http.NoBody, + }}}); err != nil { + cancel() + // gRPC Send 返回错误时无法证明服务端没有收到元数据,必须禁止自动重放。 + return nil, normalizePluginRPCError(ctx, "发送插件请求元数据", err, true) + } + sendErr := make(chan error, 1) + go func() { + err := sendPluginRequestBody(stream, request.Body) + sendErr <- err + if err != nil { + cancel() + } + }() + + first, err := stream.Recv() + if err != nil { + cancel() + select { + case bodyErr := <-sendErr: + if bodyErr != nil { + return nil, normalizePluginRPCError(ctx, "发送插件请求体", bodyErr, true) + } + default: + } + return nil, normalizePluginRPCError(ctx, "接收插件响应头", err, true) + } + if frameError := first.GetError(); frameError != nil { + cancel() + return nil, &PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent} + } + start := first.GetStart() + if start == nil || start.StatusCode < 100 || start.StatusCode > 599 { + cancel() + return nil, &PluginTransportError{ + Code: "PLUGIN_INVALID_RESPONSE", + Message: "插件未返回有效的 HTTP 响应头", + RequestSent: true, + } + } + pipeReader, pipeWriter := io.Pipe() + body := &pluginResponseBody{ + reader: pipeReader, + cancel: cancel, + done: r.finishRequest, + } + go receivePluginResponseBody(stream, pipeWriter, sendErr) + return &http.Response{ + Status: start.Status, + StatusCode: int(start.StatusCode), + Proto: start.Protocol, + ProtoMajor: int(start.ProtocolMajor), + ProtoMinor: int(start.ProtocolMinor), + Header: headersFromPlugin(start.Headers), + Body: body, + ContentLength: start.ContentLength, + Request: request, + }, nil +} + +type PluginTransportError struct { + Code string + Message string + RequestSent bool +} + +func (e *PluginTransportError) Error() string { + if e == nil { + return "插件传输失败" + } + code := strings.Map(func(value rune) rune { + if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_' || value == '-' || value == '.' { + return value + } + return -1 + }, e.Code) + if len(code) > 64 { + code = code[:64] + } + return fmt.Sprintf("插件传输失败 [%s]: %s", code, sanitizeUpstreamErrorMessage(e.Message)) +} + +func normalizePluginRPCError(ctx context.Context, operation string, err error, requestMayHaveBeenSent bool) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + return &PluginTransportError{ + Code: "PLUGIN_RPC_ERROR", + Message: fmt.Sprintf("%s: %v", operation, err), + RequestSent: requestMayHaveBeenSent, + } +} + +func sendPluginRequestBody(stream pluginv1.TransportPlugin_ForwardClient, body io.ReadCloser) error { + if body != nil { + defer func() { _ = body.Close() }() + buffer := make([]byte, 32*1024) + for { + read, err := body.Read(buffer) + if read > 0 { + chunk := append([]byte(nil), buffer[:read]...) + if sendErr := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyChunk{BodyChunk: chunk}}); sendErr != nil { + return sendErr + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + } + if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyEnd{BodyEnd: true}}); err != nil { + return err + } + return stream.CloseSend() +} + +func receivePluginResponseBody(stream pluginv1.TransportPlugin_ForwardClient, writer *io.PipeWriter, sendErr <-chan error) { + defer func() { _ = writer.Close() }() + for { + frame, err := stream.Recv() + if err == io.EOF { + return + } + if err != nil { + _ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "接收插件响应体", err, true)) + return + } + if chunk := frame.GetBodyChunk(); len(chunk) > 0 { + if _, err := writer.Write(chunk); err != nil { + return + } + continue + } + if frame.GetEnd() != nil { + select { + case err := <-sendErr: + if err != nil { + _ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "发送插件请求体", err, true)) + } + default: + } + return + } + if frameError := frame.GetError(); frameError != nil { + _ = writer.CloseWithError(&PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent}) + return + } + } +} + +type pluginResponseBody struct { + reader *io.PipeReader + cancel context.CancelFunc + done func() + once sync.Once +} + +func (b *pluginResponseBody) Read(data []byte) (int, error) { + return b.reader.Read(data) +} + +func (b *pluginResponseBody) Close() error { + var err error + b.once.Do(func() { + b.cancel() + err = b.reader.Close() + b.done() + }) + return err +} + +func headersToPlugin(headers http.Header) map[string]*pluginv1.HeaderValues { + out := make(map[string]*pluginv1.HeaderValues, len(headers)) + for key, values := range headers { + out[key] = &pluginv1.HeaderValues{Values: append([]string(nil), values...)} + } + return out +} + +func headersFromPlugin(headers map[string]*pluginv1.HeaderValues) http.Header { + out := make(http.Header, len(headers)) + for key, values := range headers { + if values != nil { + out[key] = append([]string(nil), values.Values...) + } + } + return out +} diff --git a/backend/internal/service/plugin_runtime_integration_test.go b/backend/internal/service/plugin_runtime_integration_test.go new file mode 100644 index 0000000000..42db798d37 --- /dev/null +++ b/backend/internal/service/plugin_runtime_integration_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type pluginFailingRequestBody struct{} + +func (pluginFailingRequestBody) Read([]byte) (int, error) { + return 0, errors.New("测试请求体读取失败") +} +func (pluginFailingRequestBody) Close() error { return nil } + +func TestPluginRuntimeIntegration(t *testing.T) { + packagePath := os.Getenv("SUB2API_TEST_PLUGIN_PACKAGE") + if packagePath == "" { + t.Skip("未提供 SUB2API_TEST_PLUGIN_PACKAGE,跳过本地插件进程集成测试") + } + packageFile, err := os.Open(packagePath) + require.NoError(t, err) + defer func() { _ = packageFile.Close() }() + + root := t.TempDir() + cfg := testPluginConfig(root, false) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"}) + installation, err := installer.Install(context.Background(), packageFile, nil) + require.NoError(t, err) + assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus) + for _, relative := range []string{ + "ui/index.html", + "ui/assets/styles.css", + "ui/assets/bridge-v1.js", + "ui/assets/app.js", + } { + assert.FileExists(t, filepath.Join(installation.InstallPath, filepath.FromSlash(relative))) + } + + runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime")) + require.NoError(t, err) + defer runtime.kill() + require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{ + "request_timeout_seconds":30, + "response_header_timeout_seconds":10, + "idle_connection_timeout_seconds":30, + "max_idle_connections":10, + "max_idle_connections_per_host":5, + "enable_http2":true, + "tls_min_version":"1.2", + "proxy_mode":"disabled", + "extra_headers":{"X-Plugin-Test":"enabled"} + }`))) + + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, readErr := io.ReadAll(request.Body) + require.NoError(t, readErr) + assert.Equal(t, "payload", string(body)) + assert.Equal(t, "enabled", request.Header.Get("X-Plugin-Test")) + assert.Equal(t, int64(len("payload")), request.ContentLength) + assert.Empty(t, request.TransferEncoding) + writer.Header().Set("X-Upstream", "plugin-runtime") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte("response-through-plugin")) + })) + defer upstream.Close() + + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, bytes.NewBufferString("payload")) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + require.True(t, runtime.beginRequest()) + response, err := runtime.roundTrip(context.Background(), request, "", account) + if err != nil { + runtime.finishRequest() + } + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, response.StatusCode) + assert.Equal(t, "plugin-runtime", response.Header.Get("X-Upstream")) + assert.Equal(t, "response-through-plugin", string(body)) + assert.Equal(t, int64(len("response-through-plugin")), response.ContentLength) + require.NoError(t, response.Body.Close()) + + failingRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, pluginFailingRequestBody{}) + require.NoError(t, err) + failingRequest.ContentLength = -1 + require.True(t, runtime.beginRequest()) + started := time.Now() + _, err = runtime.roundTrip(context.Background(), failingRequest, "", account) + runtime.finishRequest() + require.Error(t, err) + assert.Less(t, time.Since(started), 2*time.Second) +} diff --git a/backend/internal/service/plugin_security_regression_test.go b/backend/internal/service/plugin_security_regression_test.go new file mode 100644 index 0000000000..ae45fe4420 --- /dev/null +++ b/backend/internal/service/plugin_security_regression_test.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type pluginTokenRepository struct { + PluginRepository + installation *PluginInstallation + listErr error +} + +func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) { + if r.listErr != nil { + return nil, r.listErr + } + if r.installation == nil { + return nil, nil + } + copy := *r.installation + return []*PluginInstallation{©}, nil +} + +func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { + if r.installation == nil { + return nil, errors.New("插件不存在") + } + copy := *r.installation + return ©, nil +} + +type pluginTokenEncryptor struct{} + +func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) { + return "ENC:" + plaintext, nil +} + +func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) { + if !strings.HasPrefix(ciphertext, "ENC:") { + return "", errors.New("密文无效") + } + return strings.TrimPrefix(ciphertext, "ENC:"), nil +} + +func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) { + repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}} + first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + + token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute) + require.NoError(t, err) + require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second) + + pluginID, err := second.ResolveUIAssetToken(token) + require.NoError(t, err) + require.Equal(t, int64(42), pluginID) + + decoded, err := base64.RawURLEncoding.DecodeString(token) + require.NoError(t, err) + decoded[len(decoded)-1] ^= 1 + _, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded)) + require.Error(t, err) +} + +func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) { + repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}} + manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + + encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`) + require.NoError(t, err) + token := base64.RawURLEncoding.EncodeToString([]byte(encrypted)) + + _, err = manager.ResolveUIAssetToken(token) + require.ErrorContains(t, err, "会话无效") +} + +func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) { + manager := &PluginManager{ + repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")}, + runtimes: make(map[int64]*pluginRuntime), + localInstallations: make(map[int64]*PluginInstallation), + } + + err := manager.reconcileOnce(context.Background()) + require.ErrorContains(t, err, "读取插件启用状态") + require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + })) + + request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, requestErr) + _, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + }) + require.True(t, handled) + require.ErrorContains(t, routeErr, "插件不可用") +} + +type normalizingPluginClient struct { + pluginv1.TransportPluginClient + normalized []byte + applied []byte +} + +type pluginConfigRepository struct { + PluginRepository + installation *PluginInstallation + encrypted string +} + +func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { + copy := *r.installation + return ©, nil +} + +func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error { + if expectedBinarySHA256 != r.installation.BinarySHA256 { + return ErrPluginStateChanged + } + r.encrypted = encrypted + return nil +} + +func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) { + return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil +} + +func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) { + c.applied = append([]byte(nil), request.ConfigJson...) + return &pluginv1.ApplyConfigResponse{Applied: true}, nil +} + +func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) { + client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)} + runtime := &pluginRuntime{api: client} + + normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`)) + require.NoError(t, err) + require.JSONEq(t, `{"a":1,"z":2}`, string(normalized)) + require.Equal(t, normalized, client.applied) +} + +func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) { + client := &normalizingPluginClient{normalized: []byte(`{"broken"`)} + runtime := &pluginRuntime{api: client} + + _, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`)) + require.ErrorContains(t, err, "规范化配置") + require.Empty(t, client.applied) +} + +func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) { + installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)} + repo := &pluginConfigRepository{installation: installation} + client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)} + manager := &PluginManager{ + repo: repo, encryptor: pluginTokenEncryptor{}, + runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}}, + } + + saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`)) + require.NoError(t, err) + require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved)) + plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted) + require.NoError(t, err) + require.JSONEq(t, string(saved), plaintext) +} + +func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth} + transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true} + + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true) + + require.Same(t, transportErr, result) + var failover *UpstreamFailoverError + require.False(t, errors.As(result, &failover)) +} + +func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) { + err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true) + var transportErr *PluginTransportError + require.ErrorAs(t, err, &transportErr) + require.True(t, transportErr.RequestSent) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true) + require.Same(t, err, result) +} + +func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) { + err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false) + var transportErr *PluginTransportError + require.ErrorAs(t, err, &transportErr) + require.False(t, transportErr.RequestSent) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true) + var failover *UpstreamFailoverError + require.ErrorAs(t, result, &failover) +} + +func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true) + require.ErrorIs(t, err, context.Canceled) +} + +func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) { + manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}} + + require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)})) + require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)})) +} diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index f7733ae001..6ae24dd39e 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -596,7 +596,7 @@ func (s *PricingService) useFallbackPricing() error { } pricingFile := s.getPricingFilePath() - if err := os.WriteFile(pricingFile, data, 0644); err != nil { + if err := os.WriteFile(pricingFile, data, 0644); err != nil { //nolint:gosec // G703: 路径为配置的数据目录 + 硬编码文件名,非请求输入 logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err) } diff --git a/backend/internal/service/ratelimit_cn_providers.go b/backend/internal/service/ratelimit_cn_providers.go new file mode 100644 index 0000000000..17239e4bbc --- /dev/null +++ b/backend/internal/service/ratelimit_cn_providers.go @@ -0,0 +1,181 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "strings" + "time" +) + +// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。 +// +// 与 openai/anthropic 不同: +// - 余额不足是「可恢复」状态(充值/检测恢复后自动重新调度),不能走 handleAuthError +// 永久置 status=error。这里改为 SetTempUnschedulable,由 CN 余额检测周期任务 +// (cn_provider_balance_check_service.go)在余额恢复后 ClearTempUnschedulable。 +// - Coding Plan 滚动窗口耗尽(429)的冷却终点应是真实的窗口重置时间(已由 +// CNProviderQuotaService 落入 account.Extra 快照),而非默认的秒级兜底。 + +// cnBalanceExtraSuffixLow 标记账号响应过「余额不足」,供余额检测任务区分 +// 「确属余额不足」与「尚未探测」。 +const cnBalanceExtraSuffixLow = "balance_low" + +// cnBalanceLowReasonPrefix 是余额不足临时停调 reason 的稳定前缀。 +// 周期余额检测任务据此识别「是我们停调的」并在余额恢复后安全清除——不会误清 +// 其他子系统(阈值/限流/401)写入的临时停调。 +const cnBalanceLowReasonPrefix = "cn_balance_low" + +const kimiConcurrentRequestLimitMessage = "You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again." + +const cnConcurrencyLimitReasonPrefix = "cn_concurrency_limit" + +func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool { + return account != nil && account.Platform == PlatformKimi && + strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage +} + +func (s *RateLimitService) handleCNProviderConcurrencyLimit403( + ctx context.Context, + account *Account, +) { + until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute) + reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage + s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix) + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil { + slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err) + return + } + slog.Info("cn_provider_concurrency_limited", + "account_id", account.ID, + "platform", account.Platform, + "until", until.UTC(), + ) +} + +// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。 +func cnBalanceLowReason(upstreamMsg string) string { + if upstreamMsg = strings.TrimSpace(upstreamMsg); upstreamMsg != "" { + return cnBalanceLowReasonPrefix + ": " + upstreamMsg + } + return cnBalanceLowReasonPrefix + ": 余额不足,账号临时停调" +} + +// cnProviderResponseIndicatesInsufficientBalance 通过响应体文案识别余额不足 +// (智谱 payg 无独立余额端点,仅能靠响应文案识别)。 +func cnProviderResponseIndicatesInsufficientBalance(body []byte) bool { + if len(body) == 0 { + return false + } + s := strings.ToLower(string(body)) + return strings.Contains(s, "余额不足") || + strings.Contains(s, "insufficient balance") || + strings.Contains(s, "insufficient_credit") || + strings.Contains(s, "balance is not enough") || + strings.Contains(s, "no enough balance") +} + +// handleCNProviderInsufficientBalance 把余额不足标记为可恢复的临时停调: +// 写入 balance_low 快照 + SetTempUnschedulable 一个余额检测周期, +// 由周期任务在余额恢复后清除。返回前已通知调度阻塞。 +func (s *RateLimitService) handleCNProviderInsufficientBalance( + ctx context.Context, + account *Account, + upstreamMsg string, +) { + msg := cnBalanceLowReason(upstreamMsg) + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + cnExtraKey(account.Platform, cnBalanceExtraSuffixLow): true, + }); err != nil { + slog.Warn("cn_balance_low_mark_failed", "account_id", account.ID, "error", err) + } + + until := time.Now().Add(s.cnBalanceCooldownDuration()) + s.notifyAccountSchedulingBlocked(account, until, "cn_insufficient_balance") + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, msg); err != nil { + slog.Warn("cn_balance_set_temp_unschedulable_failed", "account_id", account.ID, "error", err) + return + } + slog.Info("cn_provider_insufficient_balance", + "account_id", account.ID, + "platform", account.Platform, + "until", until.UTC(), + ) +} + +// cnBalanceCooldownDuration 返回余额不足临时停调的持续时长(= 2× 余额检测周期, +// 默认 20 分钟)。周期任务会在余额恢复后提前清除,故此处只需保证冷却覆盖到下一次 +// 周期检测即可。 +func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration { + minutes := 10 + if s != nil && s.cfg != nil { + if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 { + minutes = cfgMin + } + } + cooldown := time.Duration(minutes) * time.Minute * 2 + if cooldown < time.Minute { + cooldown = 10 * time.Minute + } + return cooldown +} + +// cnProviderQuotaSnapshotReset 读取 Coding Plan 账号快照中最早一个仍在未来的窗口 +// 重置时间(5h / weekly)。429 多数由 5h 滚动窗口触发,取较早的重置点可避免 +// 把账号冷却到 weekly 重置(可达数天)的过度停调;如果确是 weekly 窗口耗尽, +// 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。 +// 无快照或均已过期返回 nil。 +func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time { + if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() || len(account.Extra) == 0 { + return nil + } + provider := account.Platform + var earliest *time.Time + for _, suffix := range []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} { + t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)]) + if t == nil || !t.After(now) { + continue + } + if earliest == nil || t.Before(*earliest) { + earliest = t + } + } + return earliest +} + +// applyCNProviderReactive429 处理国产供应商的 429 响应。 +// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。 +func (s *RateLimitService) applyCNProviderReactive429( + ctx context.Context, + account *Account, + headers http.Header, + responseBody []byte, +) bool { + if !account.IsCNProvider() { + return false + } + // 1) 余额不足文案:可恢复临时停调(含智谱 payg 这类无余额端点的场景)。 + if cnProviderResponseIndicatesInsufficientBalance(responseBody) { + s.handleCNProviderInsufficientBalance(ctx, account, extractUpstreamErrorMessage(responseBody)) + return true + } + // 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见 + // cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。 + if account.IsCodingPlan() { + if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil { + s.notifyAccountSchedulingBlocked(account, *until, "429") + if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return true + } + slog.Info("cn_coding_plan_rate_limited", + "account_id", account.ID, + "platform", account.Platform, + "reset_at", *until, + ) + return true + } + } + return false +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index ee5b09bbb4..ff8bfac268 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -25,6 +25,7 @@ type RateLimitService struct { cfg *config.Config geminiQuotaService *GeminiQuotaService tempUnschedCache TempUnschedCache + openAIAPIKeyHealth OpenAIAPIKeyHealthCache timeoutCounterCache TimeoutCounterCache openAI403CounterCache OpenAI403CounterCache settingService *SettingService @@ -32,6 +33,10 @@ type RateLimitService struct { runtimeBlocker AccountRuntimeBlocker usageCacheMu sync.RWMutex usageCache map[int64]*geminiUsageCacheEntry + + // OpenAI Team 联动熔断的进程内去重:teamID → 去重窗口截止时间 + openaiTeamLinkedMu sync.Mutex + openaiTeamLinkedRecent map[string]time.Time } type AccountRuntimeBlocker interface { @@ -74,6 +79,10 @@ const ( var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`) +var openCodeGoUsageLimitResetPattern = regexp.MustCompile(`(?i)\bresets\s+in\s+`) + +var openCodeGoUsageLimitDurationPartPattern = regexp.MustCompile(`(?i)^([0-9]+(?:\.[0-9]+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)\b`) + const ( openAI403CooldownMinutesDefault = 10 openAI403DisableThreshold = 3 @@ -97,6 +106,10 @@ func (s *RateLimitService) SetTimeoutCounterCache(cache TimeoutCounterCache) { s.timeoutCounterCache = cache } +func (s *RateLimitService) SetOpenAIAPIKeyHealthCache(cache OpenAIAPIKeyHealthCache) { + s.openAIAPIKeyHealth = cache +} + // SetOpenAI403CounterCache 设置 OpenAI 403 连续失败计数器(可选依赖) func (s *RateLimitService) SetOpenAI403CounterCache(cache OpenAI403CounterCache) { s.openAI403CounterCache = cache @@ -258,6 +271,11 @@ func (s *RateLimitService) CheckErrorPolicy(ctx context.Context, account *Accoun } return ErrorPolicySkipped } + // The global overload cooldown is the default for ordinary accounts. Explicit + // account policies above retain precedence over this fallback. + if statusCode == 529 { + return ErrorPolicyMatched + } if s.tryTempUnschedulable(ctx, account, statusCode, responseBody, firstRequestedModel(requestedModel)) { return ErrorPolicyTempUnscheduled } @@ -268,6 +286,9 @@ func (s *RateLimitService) CheckErrorPolicy(ctx context.Context, account *Accoun // 返回是否应该停止该账号的调度 func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, requestedModel ...string) (shouldDisable bool) { ctx = withTempUnschedulableModel(ctx, requestedModel) + // Team 联动熔断必须先于池模式/自定义错误码/临时不可调度的各类早退; + // 同请求内与 fastpath 调用点的重复触发由方法内去重吸收。 + s.maybeHandleOpenAITeamLinkedError(ctx, account, statusCode, responseBody) customErrorCodesEnabled := account.IsCustomErrorCodesEnabled() // 池模式默认不标记本地账号状态;但管理员显式配置的临时不可调度规则优先。 @@ -287,6 +308,15 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc return false } + if statusCode == 529 { + if customErrorCodesEnabled { + s.handleCustomErrorCode(ctx, account, statusCode, extractUpstreamErrorMessage(responseBody)) + return true + } + s.handle529(ctx, account) + return false + } + if len(requestedModel) > 0 && s.HandleUpstreamModelNotFound(ctx, account, requestedModel[0], statusCode, responseBody) { return true } @@ -436,6 +466,13 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc shouldDisable = true } case 402: + // 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除), + // 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。 + if account.IsCNProvider() { + s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg) + shouldDisable = true + break + } // OpenAI: deactivated_workspace 表示工作区已停用,直接标记 error if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail.code").String() == "deactivated_workspace" { msg := "Workspace deactivated (402): workspace has been deactivated" @@ -467,7 +504,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc s.handle429(ctx, account, headers, responseBody) shouldDisable = false case 529: - s.handle529(ctx, account) + // Handled after pool/custom-code policy gates above. shouldDisable = false default: // 自定义错误码启用时:在列表中的错误码都应该停止调度 @@ -899,7 +936,17 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst if account.Platform == PlatformAntigravity { return s.handleAntigravity403(ctx, account, upstreamMsg, responseBody) } - if account.Platform == PlatformOpenAI { + // Kimi reports its transient per-account concurrency/business limit as a 403. + // Keep the normal 403 failover signal (true), but never feed this exact message + // into the escalating 403 counter that can permanently mark the account error. + if isCNProviderConcurrencyLimit403(account, upstreamMsg) { + s.handleCNProviderConcurrencyLimit403(ctx, account) + return true + } + // 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据, + // 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/ + // 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。 + if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) { return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody) } // 非 Antigravity 平台:保持原有行为 @@ -1040,18 +1087,39 @@ func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *A // handle429 处理429限流错误 // 解析响应头获取重置时间,标记账号为限流状态 func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header, responseBody []byte) { + // OpenAI OAuth stays on the same account for the gateway's bounded retry + // window. Persisting a rate-limit reset on the first 429 would make the next + // retry ineligible and silently turn same-account recovery into a switch. + if account != nil && isOpenAIOAuthAccount(account) && s.runtimeBlocker != nil { + if checker, ok := s.runtimeBlocker.(interface { + ShouldRetryOpenAIOAuth429(*Account, http.Header, []byte) bool + }); ok && checker.ShouldRetryOpenAIOAuth429(account, headers, responseBody) { + return + } + } // Spark 影子:限流/熔断状态 100% 由 QueryUsage(/wham/usage body 的 codex_bengalfox)驱动。 // /responses 的 429 携带的 x-codex-*/usage_limit_reached 是 global codex 道(plan/spec §8), // 套到影子会把 spark 误耦合到 global 窗口——即便 spark 仍有配额也会被冷却到 global reset, // 单影子场景直接变成无可用账号(外审第8轮 P1)。整段跳过;影子的 codex_* 仅由 account_usage 的 // QueryUsage→persistOpenAICodexProbeSnapshot 维护,枯竭由调度守卫处理。 if account.IsShadow() { + if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) + } return } + // 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调, + // Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。 + if account.IsCNProvider() { + if s.applyCNProviderReactive429(ctx, account, headers, responseBody) { + return + } + } // 1. OpenAI 平台:优先尝试解析 x-codex-* 响应头(用于 rate_limit_exceeded) if account.Platform == PlatformOpenAI { persistOpenAI429PlanType(ctx, s.accountRepo, account, responseBody) s.persistOpenAICodexSnapshot(ctx, account, headers) + notifyOpenAIAutoReset(account.ID) if resetAt := s.calculateOpenAI429ResetTime(headers); resetAt != nil { s.notifyAccountSchedulingBlocked(account, *resetAt, "429") if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil { @@ -1567,10 +1635,12 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou } if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { slog.Warn("openai_codex_snapshot_persist_failed", "account_id", account.ID, "error", err) + return } + notifyOpenAIAutoReset(account.ID) } -// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳 +// parseOpenAIRateLimitResetTime 解析 OpenAI 兼容格式的 429 响应,返回重置时间的 Unix 时间戳 // OpenAI 的 usage_limit_reached 错误格式: // // { @@ -1592,9 +1662,9 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { return nil } - // 检查是否为 usage_limit_reached 或 rate_limit_exceeded 类型 + // 检查是否为已知的账号用量限制类型。 errType, _ := errObj["type"].(string) - if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" { + if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" && errType != "GoUsageLimitError" { return nil } @@ -1621,9 +1691,76 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { } } + // OpenCode Go subscriptions expose the reset only in a human-readable message, + // for example: "Weekly usage limit reached. Resets in 2 days." + if errType == "GoUsageLimitError" { + message, _ := errObj["message"].(string) + if resetAfter := parseOpenCodeGoUsageLimitResetDuration(message); resetAfter > 0 { + ts := time.Now().Add(resetAfter).Unix() + return &ts + } + } + return nil } +func parseOpenCodeGoUsageLimitResetDuration(message string) time.Duration { + resetPrefix := openCodeGoUsageLimitResetPattern.FindStringIndex(message) + if resetPrefix == nil { + return 0 + } + + remainder := message[resetPrefix[1]:] + var total time.Duration + for { + remainder = strings.TrimSpace(remainder) + matches := openCodeGoUsageLimitDurationPartPattern.FindStringSubmatchIndex(remainder) + if matches == nil { + break + } + + value, err := strconv.ParseFloat(remainder[matches[2]:matches[3]], 64) + if err != nil || value <= 0 { + return 0 + } + + unit := openCodeGoUsageLimitDurationUnit(remainder[matches[4]:matches[5]]) + if unit <= 0 { + return 0 + } + + const maxDuration = time.Duration(1<<63 - 1) + if value >= float64(maxDuration)/float64(unit) { + return 0 + } + part := time.Duration(value * float64(unit)) + if part <= 0 || total > maxDuration-part { + return 0 + } + total += part + remainder = remainder[matches[1]:] + } + + return total +} + +func openCodeGoUsageLimitDurationUnit(raw string) time.Duration { + switch strings.ToLower(raw) { + case "s", "sec", "secs", "second", "seconds": + return time.Second + case "m", "min", "mins", "minute", "minutes": + return time.Minute + case "h", "hr", "hrs", "hour", "hours": + return time.Hour + case "d", "day", "days": + return 24 * time.Hour + case "w", "week", "weeks": + return 7 * 24 * time.Hour + default: + return 0 + } +} + func parseOpenAIRateLimitPlanType(body []byte) string { var parsed map[string]any if err := json.Unmarshal(body, &parsed); err != nil { diff --git a/backend/internal/service/ratelimit_service_401_test.go b/backend/internal/service/ratelimit_service_401_test.go index 48e6a41def..c1f9f06954 100644 --- a/backend/internal/service/ratelimit_service_401_test.go +++ b/backend/internal/service/ratelimit_service_401_test.go @@ -25,6 +25,7 @@ type rateLimitAccountRepoStub struct { lastTempReason string lastErrorID int64 lastTempID int64 + tempErr error } func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error { @@ -38,7 +39,7 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id r.tempCalls++ r.lastTempID = id r.lastTempReason = reason - return nil + return r.tempErr } func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error { diff --git a/backend/internal/service/ratelimit_service_openai_image_test.go b/backend/internal/service/ratelimit_service_openai_image_test.go index 76d3bb764b..26714cf123 100644 --- a/backend/internal/service/ratelimit_service_openai_image_test.go +++ b/backend/internal/service/ratelimit_service_openai_image_test.go @@ -119,3 +119,53 @@ func TestOpenAIGatewayServiceForwardImages_ImageRateLimitReturnsFailoverAndCools require.Len(t, repo.modelRateLimitCalls, 1) require.Equal(t, openAIImageGenerationRateLimitKey, repo.modelRateLimitCalls[0].scope) } + +func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &modelNotFoundAccountRepoStub{} + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`) + upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"Here's a polished image prompt for your request.\"}]}]}}\n\n" + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{ + accountRepo: repo, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + account := &Account{ + ID: 205, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + before := time.Now() + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + + require.Nil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.False(t, failoverErr.RetryableOnSameAccount) + require.Len(t, repo.modelRateLimitCalls, 1) + call := repo.modelRateLimitCalls[0] + require.Equal(t, account.ID, call.accountID) + require.Equal(t, openAIImageGenerationRateLimitKey, call.scope) + require.Equal(t, openAIImagesOAuthUnavailableReason, call.reason) + require.WithinDuration(t, before.Add(openAIImagesOAuthUnavailableCooldown), call.resetAt, time.Second) +} diff --git a/backend/internal/service/ratelimit_service_openai_team_linked_test.go b/backend/internal/service/ratelimit_service_openai_team_linked_test.go new file mode 100644 index 0000000000..e72763e263 --- /dev/null +++ b/backend/internal/service/ratelimit_service_openai_team_linked_test.go @@ -0,0 +1,192 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +const teamLinkedDeactivatedBody = `{"detail":{"code":"deactivated_workspace","message":"This workspace has been deactivated."}}` + +type teamLinkedAccountRepoStub struct { + mockAccountRepoForGemini + teamAccounts []Account + listErr error + listCalls int + setErrorIDs []int64 + setErrorMsgs map[int64]string + failSetError map[int64]error +} + +// ListByPlatform 镜像真实仓库语义:仅返回该平台的 active 账户。 +func (r *teamLinkedAccountRepoStub) ListByPlatform(ctx context.Context, platform string) ([]Account, error) { + r.listCalls++ + if r.listErr != nil { + return nil, r.listErr + } + out := make([]Account, 0, len(r.teamAccounts)) + for _, acc := range r.teamAccounts { + if acc.Platform == platform && acc.Status == StatusActive { + out = append(out, acc) + } + } + return out, nil +} + +func (r *teamLinkedAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error { + if err, ok := r.failSetError[id]; ok { + return err + } + r.setErrorIDs = append(r.setErrorIDs, id) + if r.setErrorMsgs == nil { + r.setErrorMsgs = make(map[int64]string) + } + r.setErrorMsgs[id] = errorMsg + return nil +} + +func newTeamLinkedAccount(id int64, teamID string) Account { + return Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": teamID}, + } +} + +// newTeamLinkedFixture: #1 触发者(team-A) #2 同队 #3 异队 #4 apikey #5 影子 #6 同队 #7 同队但已 error +func newTeamLinkedFixture() []Account { + parentID := int64(1) + shadow := Account{ + ID: 5, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ParentAccountID: &parentID, + } + apikey := newTeamLinkedAccount(4, "team-A") + apikey.Type = AccountTypeAPIKey + erroredSibling := newTeamLinkedAccount(7, "team-A") + erroredSibling.Status = StatusError + return []Account{ + newTeamLinkedAccount(1, "team-A"), + newTeamLinkedAccount(2, "team-A"), + newTeamLinkedAccount(3, "team-B"), + apikey, + shadow, + newTeamLinkedAccount(6, "team-A"), + erroredSibling, + } +} + +func newTeamLinkedTestService(repo *teamLinkedAccountRepoStub) (*RateLimitService, *runtimeBlockRecorder) { + rl := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + blocker := &runtimeBlockRecorder{} + rl.SetAccountRuntimeBlocker(blocker) + return rl, blocker +} + +func TestTeamLinkedError_FanoutMarksSameTeamAccounts(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, blocker := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + shouldDisable := rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + + require.True(t, shouldDisable) + // fan-out 先标记同队兄弟(#2、#6),触发账户 #1 随后由常规 case 402 标记 + require.Equal(t, []int64{2, 6, 1}, repo.setErrorIDs) + require.Contains(t, repo.setErrorMsgs[2], "team-linked error triggered by account #1") + require.Contains(t, repo.setErrorMsgs[6], "team-linked error triggered by account #1") + require.Contains(t, repo.setErrorMsgs[1], "Workspace deactivated (402)") + require.NotContains(t, repo.setErrorMsgs[1], "team-linked") + // 熔断顺序:兄弟账户先于落库全部进程内熔断,触发账户走 auth_error + require.Equal(t, []string{openAITeamLinkedErrorBlockReason, openAITeamLinkedErrorBlockReason, "auth_error"}, blocker.reasons) + require.Equal(t, int64(2), blocker.accounts[0].ID) + require.Equal(t, int64(6), blocker.accounts[1].ID) + require.Equal(t, int64(1), blocker.accounts[2].ID) +} + +func TestTeamLinkedError_GenericPaymentErrorDoesNotFanout(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, _ := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(`{"error":{"message":"insufficient balance"}}`)) + + require.Equal(t, []int64{1}, repo.setErrorIDs) + require.Contains(t, repo.setErrorMsgs[1], "Payment required (402)") + require.Zero(t, repo.listCalls) +} + +func TestTeamLinkedError_DedupWithinTTL(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, _ := newTeamLinkedTestService(repo) + first := newTeamLinkedAccount(1, "team-A") + second := newTeamLinkedAccount(2, "team-A") + + rl.HandleUpstreamError(context.Background(), &first, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + rl.HandleUpstreamError(context.Background(), &second, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + + // 第二次触发被去重:只有 #2 自身经 case 402 标记,未再次 fan-out + require.Equal(t, []int64{2, 6, 1, 2}, repo.setErrorIDs) + require.Equal(t, 1, repo.listCalls) +} + +func TestTeamLinkedError_APIKeyTriggerDoesNotFanout(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, _ := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(4, "team-A") + trigger.Type = AccountTypeAPIKey + + rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + + require.Equal(t, []int64{4}, repo.setErrorIDs) + require.Zero(t, repo.listCalls) +} + +func TestTeamLinkedError_DirectCallSkipsTriggerAccount(t *testing.T) { + // 直调对应 fastpath 调用点:账户级临时不可调度规则短路时联动仍然生效 + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, blocker := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody)) + + require.Equal(t, []int64{2, 6}, repo.setErrorIDs) + require.Equal(t, []string{openAITeamLinkedErrorBlockReason, openAITeamLinkedErrorBlockReason}, blocker.reasons) +} + +func TestTeamLinkedError_MissingTeamIDDoesNothing(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, blocker := newTeamLinkedTestService(repo) + trigger := Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive} + + rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody)) + + require.Empty(t, repo.setErrorIDs) + require.Empty(t, blocker.reasons) + require.Zero(t, repo.listCalls) +} + +func TestTeamLinkedError_SetErrorFailureDoesNotAbortRemaining(t *testing.T) { + repo := &teamLinkedAccountRepoStub{ + teamAccounts: newTeamLinkedFixture(), + failSetError: map[int64]error{2: errors.New("db down")}, + } + rl, blocker := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody)) + + require.Equal(t, []int64{6}, repo.setErrorIDs) + // 进程内熔断先于落库执行,两个账户都已被熔断 + require.Len(t, blocker.reasons, 2) +} diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index 4b619e15b2..8969f7e622 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -117,6 +117,49 @@ func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) { } } +func TestParseOpenAIRateLimitResetTime_OpenCodeGoUsageLimit(t *testing.T) { + tests := []struct { + name string + body string + want time.Duration + }{ + { + name: "days", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 2 days."}}`, + want: 48 * time.Hour, + }, + { + name: "hours", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 18 hours."}}`, + want: 18 * time.Hour, + }, + { + name: "hours and minutes", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min."}}`, + want: 4*time.Hour + 59*time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := time.Now() + resetAt := parseOpenAIRateLimitResetTime([]byte(tt.body)) + after := time.Now() + + require.NotNil(t, resetAt) + actual := time.Unix(*resetAt, 0) + require.False(t, actual.Before(before.Add(tt.want).Truncate(time.Second))) + require.False(t, actual.After(after.Add(tt.want))) + }) + } +} + +func TestParseOpenAIRateLimitResetTime_DoesNotParseUnknownErrorMessage(t *testing.T) { + body := []byte(`{"error":{"type":"rate_limit_error","message":"Resets in 2 days."}}`) + + require.Nil(t, parseOpenAIRateLimitResetTime(body)) +} + func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) { svc := &RateLimitService{} diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index 00454a498d..0f6003a01a 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -114,6 +114,21 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing. require.Empty(t, deleted) } +func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + t.Run(platform, func(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{1}, []int64{11}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, schedulerBucketsForTest([]int64{11, 12}, platform), cache.capturedBuckets()) + }) + } +} + func TestSchedulerBulkAccountEventRebuildsOpenAIUngroupedBucket(t *testing.T) { cache := newBulkEventSnapshotCache() repo := newBulkEventAccountRepo(&Account{ID: 6, Platform: PlatformOpenAI}) diff --git a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go index fa918a0862..e52adbb4c8 100644 --- a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go @@ -290,13 +290,13 @@ func TestSchedulerFullRebuildActiveTombstoneDoesNotBlockFollowingGroupEvent(t *t require.Equal(t, 1, activeCalls) require.Zero(t, fallbackCalls) require.Equal(t, []int64{groupID, groupID}, freshCalls) - require.Len(t, cache.tokens(), 24, "full rebuild and the following group event must each run fresh authority") + require.Len(t, cache.tokens(), 36, "full rebuild and the following group event must each run fresh authority") _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 24) + require.Len(t, reopenHeld, 36) for _, held := range reopenHeld { require.True(t, held) } - require.Equal(t, 21, accounts.callCount()) + require.Equal(t, 30, accounts.callCount()) } func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) { @@ -367,15 +367,15 @@ func TestSchedulerFullRebuildFreshActivePreparesEveryTokenBeforeFirstDB(t *testi capturesAtFirstDB = cache.captureAttemptCount() held, reopenCount := cache.leaseHeldAndTokenCount() require.False(t, held) - require.Equal(t, 12, reopenCount) - require.Equal(t, 13, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB") + require.Equal(t, 18, reopenCount) + require.Equal(t, 19, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB") } svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount()) - require.Equal(t, 15, accounts.callCount()) - require.Equal(t, 8, accounts.groupCallCount(groupID)) + require.Equal(t, 21, accounts.callCount()) + require.Equal(t, 11, accounts.groupCallCount(groupID)) _, historicalPublished := cache.counts(historical) require.Equal(t, 1, historicalPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -398,7 +398,7 @@ func TestSchedulerFullRebuildOrdinaryCaptureErrorReturnsBeforeFirstDB(t *testing err := svc.rebuildFullSnapshot(context.Background(), "test") require.ErrorIs(t, err, wantErr) - require.Equal(t, 14, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") + require.Equal(t, 20, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) } @@ -416,8 +416,8 @@ func TestSchedulerFullRebuildPreservesGroupZeroActiveHistoricalAndInvalidRegistr svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) - require.Equal(t, 27, cache.captureAttemptCount()) - require.Equal(t, 17, accounts.callCount()) + require.Equal(t, 39, cache.captureAttemptCount()) + require.Equal(t, 23, accounts.callCount()) groups.mu.Lock() require.Equal(t, 1, groups.listCalls) groups.mu.Unlock() @@ -456,7 +456,7 @@ func TestSchedulerFullRebuildActiveTombstoneFreshInactiveOrMissingFiltersAllGrou require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Zero(t, accounts.groupCallCount(groupID)) - require.Equal(t, 7, accounts.groupCallCount(0)) + require.Equal(t, 10, accounts.groupCallCount(0)) require.Empty(t, cache.tokens()) require.Equal(t, bucketStrings(append(canonical, historical)), bucketStrings(cache.retiredBuckets())) for _, bucket := range append(canonical, historical) { @@ -528,7 +528,7 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t require.Equal(t, []int64{1, 2}, freshCalls) require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) - require.Equal(t, 13, len(cache.retiredBuckets())) + require.Equal(t, 19, len(cache.retiredBuckets())) groups.mu.Lock() delete(groups.freshErr, 2) @@ -537,8 +537,8 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t require.NoError(t, svc.triggerFullRebuild("retry")) _, _, freshCalls = groups.stats() require.Equal(t, []int64{1, 2, 2, 3}, freshCalls) - require.Equal(t, 39, len(cache.retiredBuckets())) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 57, len(cache.retiredBuckets())) + require.Equal(t, 10, accounts.callCount()) require.Empty(t, cache.tokens()) } @@ -561,14 +561,14 @@ func TestSchedulerFullRebuildActiveTombstoneLazyRecoveryDiscardsPartialCaptureTa capturesAtFirstDB = cache.captureAttemptCount() held, reopenCount := cache.leaseHeldAndTokenCount() require.False(t, held) - require.Equal(t, 12, reopenCount) - require.Equal(t, 19, capturesAtFirstDB) + require.Equal(t, 18, reopenCount) + require.Equal(t, 25, capturesAtFirstDB) } svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount()) - require.Equal(t, 15, accounts.callCount()) + require.Equal(t, 21, accounts.callCount()) for _, bucket := range canonical { attempts, published := cache.counts(bucket) require.Equal(t, 1, attempts, "discarded pre-recovery tokens must never publish: %s", bucket.String()) @@ -600,9 +600,9 @@ func TestSchedulerFullRebuildSimpleModePreservesRegistryWithoutLifecycleAuthorit require.Zero(t, activeCalls) require.Zero(t, fallbackCalls) require.Empty(t, freshCalls) - require.Equal(t, 15, cache.captureAttemptCount()) - require.Equal(t, 10, accounts.callCount()) - require.Equal(t, 10, accounts.groupCallCount(0)) + require.Equal(t, 21, cache.captureAttemptCount()) + require.Equal(t, 13, accounts.callCount()) + require.Equal(t, 13, accounts.groupCallCount(0)) require.Empty(t, cache.retiredBuckets()) require.Empty(t, cache.tokens()) for _, bucket := range registered { @@ -632,11 +632,11 @@ func TestSchedulerFullRebuildFreshReopenLockBusyRetriesWithoutBlockingOrdinaryTa require.Zero(t, cache.currentWatermark()) _, groupZeroPublished := cache.counts(schedulerCanonicalBuckets(0)[0]) require.Equal(t, 1, groupZeroPublished, "ordinary tasks must still run when one strict Reopen task is busy") - require.Equal(t, 14, accounts.callCount()) + require.Equal(t, 20, accounts.callCount()) svc.pollOutbox() require.Equal(t, int64(1), cache.currentWatermark()) - require.Equal(t, 28, accounts.callCount()) + require.Equal(t, 40, accounts.callCount()) _, busyBucketPublished := cache.counts(canonical[0]) require.Equal(t, 1, busyBucketPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -654,7 +654,7 @@ func TestSchedulerFullRebuildOrdinaryLockBusyKeepsExistingSkipSemantics(t *testi svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 10, accounts.callCount()) attempts, published := cache.counts(busyBucket) require.Zero(t, attempts) require.Zero(t, published) diff --git a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go index ffc697eafc..f9e75ed158 100644 --- a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go @@ -325,8 +325,8 @@ func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountReposito } func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket { - platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} - buckets := make([]SchedulerBucket, 0, 12) + platforms := schedulerSnapshotPlatforms() + buckets := make([]SchedulerBucket, 0, 18) for _, platform := range platforms { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -441,7 +441,7 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes accounts.beforeLoad = func() { held, tokenCount := cache.leaseHeldAndTokenCount() require.False(t, held, "the group lifecycle lease must be released before the first account query") - require.Equal(t, 12, tokenCount, "all reopen tokens must be prepared before the first account query") + require.Equal(t, 18, tokenCount, "all reopen tokens must be prepared before the first account query") } svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard) seen := make(map[batchSeenKey]struct{}) @@ -453,8 +453,8 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes registered, err := cache.retirementRaceCache.ListBuckets(context.Background()) require.NoError(t, err) require.Contains(t, bucketStrings(registered), historical.String()) - require.Len(t, cache.tokens(), 12) - require.Equal(t, 7, accounts.callCount()) + require.Len(t, cache.tokens(), 18) + require.Equal(t, 10, accounts.callCount()) require.Equal(t, 1, accounts.platformCallCount(PlatformOpenAI)) for _, bucket := range current { _, published := cache.counts(bucket) @@ -472,16 +472,16 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes require.True(t, cache.releaseDeadline) require.NoError(t, cache.releaseCtxErr) _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 12) + require.Len(t, reopenHeld, 18) for _, held := range reopenHeld { require.True(t, held) } lockTTLs, unlockCalls := cache.lockStats() - require.Len(t, lockTTLs, 12) + require.Len(t, lockTTLs, 18) for _, ttl := range lockTTLs { require.Equal(t, 30*time.Second, ttl) } - require.Equal(t, 12, unlockCalls) + require.Equal(t, 18, unlockCalls) requireLifecycleSeen(t, seen, groupID) } @@ -497,8 +497,8 @@ func TestSchedulerGroupLifecycleInactiveThenActiveAuthoritativelyReopens(t *test groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) - require.Len(t, cache.tokens(), 12) - require.Equal(t, 7, accounts.callCount()) + require.Len(t, cache.tokens(), 18) + require.Equal(t, 10, accounts.callCount()) for _, bucket := range expectedGroupLifecycleBuckets(groupID) { _, published := cache.counts(bucket) require.Equal(t, 1, published, bucket.String()) @@ -546,15 +546,15 @@ func TestSchedulerGroupLifecycleEpochPreventsABA(t *testing.T) { groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) firstActiveTokens := cache.tokens() - require.Len(t, firstActiveTokens, 12) + require.Len(t, firstActiveTokens, 18) groups.set(&Group{ID: groupID, Status: StatusDisabled, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) allTokens := cache.tokens() - require.Len(t, allTokens, 24) - require.Greater(t, allTokens[12].Epoch, firstActiveTokens[0].Epoch) + require.Len(t, allTokens, 36) + require.Greater(t, allTokens[18].Epoch, firstActiveTokens[0].Epoch) require.ErrorIs(t, cache.SetSnapshot(context.Background(), firstActiveTokens[0].Bucket, firstActiveTokens[0], nil), ErrSchedulerBucketWriteFenced) } @@ -571,11 +571,11 @@ func TestSchedulerGroupLifecycleSeenIsIndependentAndDeduplicatesGroupEvents(t *t require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)) require.Equal(t, 1, groups.callCount()) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 10, accounts.callCount()) requireLifecycleSeen(t, seen, groupID) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)) require.Equal(t, 1, groups.callCount()) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 10, accounts.callCount()) } func TestSchedulerGroupLifecycleFailuresDoNotMarkSeen(t *testing.T) { diff --git a/backend/internal/service/scheduler_snapshot_retirement_test.go b/backend/internal/service/scheduler_snapshot_retirement_test.go index 3d24179758..6fa2b7ddc5 100644 --- a/backend/internal/service/scheduler_snapshot_retirement_test.go +++ b/backend/internal/service/scheduler_snapshot_retirement_test.go @@ -176,7 +176,7 @@ func TestSchedulerFullRebuildCapturesAllRegistryTokensBeforeDBLoad(t *testing.T) } captures, reopens := cache.captureAndReopenCounts() - require.Equal(t, 24, captures, "group0 and active-group canonical tokens must be captured before the first DB load") + require.Equal(t, 36, captures, "group0 and active-group canonical tokens must be captured before the first DB load") require.Zero(t, reopens) require.NoError(t, cache.RetireBucket(context.Background(), queued)) _, err := cache.ReopenBucket(context.Background(), queued) diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 9655f54f25..e38d0291ba 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -587,7 +587,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p rebuildGroupIDs = append(rebuildGroupIDs, gid) } - // 缺失账户无法确定原平台,保留五平台重建以避免遗留旧快照。 + // 缺失账户无法确定原平台,保留全平台重建以避免遗留旧快照。 if !allAccountsFound { return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen) } @@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok: + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: addPlatformGroups(account.Platform, accountGroupIDs) case PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 @@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account return s.rebuildBuckets(ctx, buckets, reason) } -func schedulerSnapshotPlatforms() [5]string { - return [5]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} +func schedulerSnapshotPlatforms() [8]string { + return [8]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} } // 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。 @@ -837,7 +837,7 @@ func schedulerBucketsForGroup(groupID int64) []SchedulerBucket { } func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket { - buckets := make([]SchedulerBucket, 0, 12) + buckets := make([]SchedulerBucket, 0, 18) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -855,7 +855,7 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI if len(groupIDs) == 0 { return nil } - buckets := make([]SchedulerBucket, 0, len(groupIDs)*12) + buckets := make([]SchedulerBucket, 0, len(groupIDs)*18) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...) } diff --git a/backend/internal/service/service_tier_billing.go b/backend/internal/service/service_tier_billing.go new file mode 100644 index 0000000000..74cce9146e --- /dev/null +++ b/backend/internal/service/service_tier_billing.go @@ -0,0 +1,107 @@ +package service + +import ( + "log/slog" + "strings" +) + +// ServiceTierBillingResolution describes how the billable service tier of one +// request was settled between the tier the client asked for and the tier the +// upstream reports having used. +type ServiceTierBillingResolution struct { + Requested string // tier carried by the request sent upstream ("" when none) + Observed string // tier declared by the upstream response ("" when none) + Billing string // tier used for billing and the usage log + Downgraded bool // Billing is cheaper than Requested +} + +// ResolveBillingServiceTier picks the tier to bill. The upstream declaration is +// trusted only to lower the bill: a request served below the tier it asked for +// (OpenAI "priority" answered with service_tier "default", Anthropic speed=fast +// answered with usage.speed "standard") is billed at the cheaper tier. A response +// that claims a more expensive tier, an unknown one, or none at all leaves the +// requested tier in place, which is also the behaviour for upstreams that never +// declare a tier. +func ResolveBillingServiceTier(requested, observed string) ServiceTierBillingResolution { + requested = normalizeBillingServiceTier(requested) + observed = normalizeBillingServiceTier(observed) + resolution := ServiceTierBillingResolution{Requested: requested, Observed: observed, Billing: requested} + if observed == "" || observed == requested { + return resolution + } + observedRank, known := serviceTierCostRank(observed) + if !known { + return resolution + } + requestedRank, _ := serviceTierCostRank(requested) + if observedRank >= requestedRank { + return resolution + } + resolution.Billing = observed + resolution.Downgraded = true + return resolution +} + +// serviceTierCostRank orders tiers by their cost relative to the base rate, so a +// lower rank is always cheaper. Unknown tiers rank as the base rate and report +// known=false so callers can refuse to act on them. +func serviceTierCostRank(tier string) (rank int, known bool) { + switch normalizeBillingServiceTier(tier) { + case "flex": + return 0, true + case "", "default", "standard", "auto", "scale": + return 1, true + case "priority", "fast": + return 2, true + default: + return 1, false + } +} + +// ApplyOpenAIServiceTierBillingResolution lowers result.ServiceTier to the tier +// the upstream reports having used, so cost calculation and the usage log share +// one billable tier. The returned resolution is meant for the audit log. +func ApplyOpenAIServiceTierBillingResolution(result *OpenAIForwardResult) ServiceTierBillingResolution { + if result == nil { + return ServiceTierBillingResolution{} + } + resolution := ResolveBillingServiceTier(optionalStringValue(result.ServiceTier), result.UpstreamResponseServiceTier) + if resolution.Downgraded { + billing := resolution.Billing + result.ServiceTier = &billing + } + return resolution +} + +// ApplyForwardServiceTierBillingResolution is the ForwardResult counterpart of +// ApplyOpenAIServiceTierBillingResolution. +func ApplyForwardServiceTierBillingResolution(result *ForwardResult) ServiceTierBillingResolution { + if result == nil { + return ServiceTierBillingResolution{} + } + resolution := ResolveBillingServiceTier(optionalStringValue(result.ServiceTier), result.UpstreamResponseServiceTier) + if resolution.Downgraded { + billing := resolution.Billing + result.ServiceTier = &billing + } + return resolution +} + +// logServiceTierBillingDowngrade leaves an audit trail for every request billed +// below the tier it asked for; unchanged tiers are not logged. +func logServiceTierBillingDowngrade(component string, account *Account, requestID string, resolution ServiceTierBillingResolution) { + if !resolution.Downgraded { + return + } + attrs := []any{ + "component", component, + "request_id", strings.TrimSpace(requestID), + "requested_tier", resolution.Requested, + "response_tier", resolution.Observed, + "billed_tier", resolution.Billing, + } + if account != nil { + attrs = append(attrs, "platform", account.Platform, "account_id", account.ID) + } + slog.Info("billing.service_tier_downgraded", attrs...) +} diff --git a/backend/internal/service/service_tier_billing_test.go b/backend/internal/service/service_tier_billing_test.go new file mode 100644 index 0000000000..fc8242fa2b --- /dev/null +++ b/backend/internal/service/service_tier_billing_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveBillingServiceTier(t *testing.T) { + tests := []struct { + name string + requested string + observed string + billing string + downgraded bool + }{ + {name: "openai priority served as default", requested: "priority", observed: "default", billing: "default", downgraded: true}, + {name: "anthropic fast served as standard", requested: "fast", observed: "standard", billing: "standard", downgraded: true}, + {name: "priority honoured", requested: "priority", observed: "priority", billing: "priority"}, + {name: "no declaration keeps request", requested: "priority", observed: "", billing: "priority"}, + {name: "no request no declaration", requested: "", observed: "", billing: ""}, + {name: "response never raises the tier", requested: "", observed: "priority", billing: ""}, + {name: "flex never raised to default", requested: "flex", observed: "default", billing: "flex"}, + {name: "default echoed for untiered request", requested: "", observed: "default", billing: ""}, + {name: "unknown response tier ignored", requested: "priority", observed: "turbo", billing: "priority"}, + {name: "case and whitespace normalised", requested: " Priority ", observed: "DEFAULT", billing: "default", downgraded: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveBillingServiceTier(tt.requested, tt.observed) + require.Equal(t, tt.billing, got.Billing) + require.Equal(t, tt.downgraded, got.Downgraded) + }) + } +} + +func TestApplyServiceTierBillingResolutionOnlyRewritesDowngrades(t *testing.T) { + t.Run("openai downgrade rewrites tier", func(t *testing.T) { + requested := "priority" + result := &OpenAIForwardResult{ServiceTier: &requested, UpstreamResponseServiceTier: "default"} + resolution := ApplyOpenAIServiceTierBillingResolution(result) + require.True(t, resolution.Downgraded) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) + }) + + t.Run("openai honoured tier keeps pointer", func(t *testing.T) { + requested := "priority" + result := &OpenAIForwardResult{ServiceTier: &requested, UpstreamResponseServiceTier: "priority"} + require.False(t, ApplyOpenAIServiceTierBillingResolution(result).Downgraded) + require.Same(t, &requested, result.ServiceTier) + }) + + t.Run("openai untiered request stays nil", func(t *testing.T) { + result := &OpenAIForwardResult{UpstreamResponseServiceTier: "priority"} + require.False(t, ApplyOpenAIServiceTierBillingResolution(result).Downgraded) + require.Nil(t, result.ServiceTier) + }) + + t.Run("anthropic standard speed rewrites fast", func(t *testing.T) { + requested := "fast" + result := &ForwardResult{ServiceTier: &requested, UpstreamResponseServiceTier: "standard"} + require.True(t, ApplyForwardServiceTierBillingResolution(result).Downgraded) + require.Equal(t, "standard", *result.ServiceTier) + }) + + t.Run("nil results are ignored", func(t *testing.T) { + require.False(t, ApplyOpenAIServiceTierBillingResolution(nil).Downgraded) + require.False(t, ApplyForwardServiceTierBillingResolution(nil).Downgraded) + }) +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 2f13278e3b..e7eda7c30e 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -457,6 +457,37 @@ func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx cont return nil } +// MigrateGrokDefaultTextModel upgrades the pre-4.6 built-in default for +// existing installations. Explicit operator choices are left untouched. +func (s *SettingService) MigrateGrokDefaultTextModel(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultTextModel) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return nil + } + return fmt.Errorf("get %s setting: %w", SettingKeyGrokDefaultTextModel, err) + } + // Only migrate the value that was previously shipped as the built-in + // default. Any other value is an explicit operator choice or a future + // default and must remain unchanged. + if strings.TrimSpace(value) != "grok-4.5" { + return nil + } + if err := s.settingRepo.Set(dbCtx, SettingKeyGrokDefaultTextModel, "grok-4.6"); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyGrokDefaultTextModel, err) + } + return nil +} + // MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint // 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, // 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 diff --git a/backend/internal/service/setting_openai_apikey_health.go b/backend/internal/service/setting_openai_apikey_health.go new file mode 100644 index 0000000000..66886b5e8b --- /dev/null +++ b/backend/internal/service/setting_openai_apikey_health.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const openAIAPIKeyHealthBreakerSettingsCacheTTL = 30 * time.Second + +type cachedOpenAIAPIKeyHealthBreakerSettings struct { + settings OpenAIAPIKeyHealthBreakerSettings + expiresAt time.Time +} + +func normalizeOpenAIAPIKeyHealthBreakerSettings(settings *OpenAIAPIKeyHealthBreakerSettings) *OpenAIAPIKeyHealthBreakerSettings { + if settings == nil { + return DefaultOpenAIAPIKeyHealthBreakerSettings() + } + result := *settings + if result.WindowMinutes < 1 { + result.WindowMinutes = 1 + } else if result.WindowMinutes > 60 { + result.WindowMinutes = 60 + } + if result.FailureThreshold < 1 { + result.FailureThreshold = 1 + } else if result.FailureThreshold > 10000 { + result.FailureThreshold = 10000 + } + if result.CooldownMinutes < 1 { + result.CooldownMinutes = 1 + } else if result.CooldownMinutes > 60 { + result.CooldownMinutes = 60 + } + return &result +} + +func (s *SettingService) GetOpenAIAPIKeyHealthBreakerSettings(ctx context.Context) (*OpenAIAPIKeyHealthBreakerSettings, error) { + if s == nil || s.settingRepo == nil { + return DefaultOpenAIAPIKeyHealthBreakerSettings(), nil + } + if cached, ok := s.openAIAPIKeyHealthBreakerCache.Load().(*cachedOpenAIAPIKeyHealthBreakerSettings); ok && cached != nil && time.Now().Before(cached.expiresAt) { + result := cached.settings + return &result, nil + } + + settings := DefaultOpenAIAPIKeyHealthBreakerSettings() + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIAPIKeyHealthBreakerSettings) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return nil, fmt.Errorf("get OpenAI API key health breaker settings: %w", err) + } + if err == nil && strings.TrimSpace(value) != "" { + var stored OpenAIAPIKeyHealthBreakerSettings + if json.Unmarshal([]byte(value), &stored) == nil { + settings = normalizeOpenAIAPIKeyHealthBreakerSettings(&stored) + } + } + s.openAIAPIKeyHealthBreakerCache.Store(&cachedOpenAIAPIKeyHealthBreakerSettings{ + settings: *settings, + expiresAt: time.Now().Add(openAIAPIKeyHealthBreakerSettingsCacheTTL), + }) + result := *settings + return &result, nil +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 283c76f8f4..a1a0c218e3 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -190,9 +190,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorMode: ChannelMonitorModeV1, SettingKeyChannelMonitorDefaultIntervalSeconds: "60", SettingKeyChannelMonitorHideThroughput: "true", + SettingKeyChannelMonitorShowQuota: "false", // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. - SettingKeyGrokDefaultTextModel: "grok-4.5", + SettingKeyGrokDefaultTextModel: "grok-4.6", SettingKeyGrokCrossClientModelMapEnabled: "true", SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, @@ -200,9 +201,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyAvailableChannelsEnabled: "false", // Model plaza feature (default disabled; opt-in, public unless require_auth) - SettingKeyModelPlazaEnabled: "false", - SettingKeyModelPlazaRequireAuth: "false", - SettingKeyModelPlazaDescription: "", + SettingKeyModelPlazaEnabled: "false", + SettingKeyModelPlazaRequireAuth: "false", + SettingKeyModelPlazaDescription: "", + SettingKeyPluginManagementEnabled: "false", // Affiliate (邀请返利) feature (default disabled; opt-in) SettingKeyAffiliateEnabled: "false", @@ -798,11 +800,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) + // 配额展示默认关闭且 fail-closed:仅字面 "true" 视为开启 + // (与 setting_public.go 公开读取路径保持一致)。 + result.ChannelMonitorShowQuota = settings[SettingKeyChannelMonitorShowQuota] == "true" // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) if result.GrokDefaultTextModel == "" { - result.GrokDefaultTextModel = "grok-4.5" + result.GrokDefaultTextModel = "grok-4.6" } // Default true (missing/empty → enabled) so Claude/Codex→Grok mapping keeps working. // Operators can set false to disable silent cross-client rewrite. @@ -816,6 +821,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true" result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true" result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription] + result.PluginManagementEnabled = settings[SettingKeyPluginManagementEnabled] == "true" // Affiliate (邀请返利) feature (default: disabled; strict true) result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index f292e8ac57..7072dd7c03 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -231,9 +231,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, + SettingKeyChannelMonitorShowQuota, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, + SettingKeyPluginManagementEnabled, SettingKeyAffiliateEnabled, SettingKeyRiskControlEnabled, SettingKeyAllowUserViewErrorRequests, @@ -355,11 +357,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), + ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true", AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true", - ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true", + ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true", + ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true", + PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true", AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", @@ -422,6 +426,10 @@ type ChannelMonitorRuntime struct { DefaultIntervalSeconds int // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals. HideThroughput bool + // ShowQuota: when true, user-facing monitor views keep the quota/balance + // snapshots; otherwise the user handler strips them server-side. + // Parsed fail-closed (only literal "true" enables). Admin always sees them. + ShowQuota bool } // ActiveProbesAllowed reports whether V1 active provider probes may run. @@ -450,6 +458,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, + SettingKeyChannelMonitorShowQuota, }) if err != nil { return ChannelMonitorRuntime{ @@ -464,6 +473,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]), DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), + ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true", } } @@ -606,12 +616,16 @@ type PublicSettingsInjectionPayload struct { // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM // without waiting for API redaction alone (defense in depth). ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` + // ChannelMonitorShowQuota gates the user-facing quota/balance display on + // monitors; fail-closed (absent/false = hidden). Admin UI always shows it. + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -685,9 +699,11 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + PluginManagementEnabled: settings.PluginManagementEnabled, AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f9091e8a27..06a1b19f1a 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -149,6 +149,7 @@ type SettingService struct { // instance owns its own cache, no shared package-level state. openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings openAIQuotaAutoPauseSettingsSF singleflight.Group + openAIAPIKeyHealthBreakerCache atomic.Value // *cachedOpenAIAPIKeyHealthBreakerSettings channelMonitorRuntimeListenersMu sync.Mutex channelMonitorRuntimeListeners []func() diff --git a/backend/internal/service/setting_service_codex_policy_test.go b/backend/internal/service/setting_service_codex_policy_test.go index 1c17b91ef8..e426dea2c5 100644 --- a/backend/internal/service/setting_service_codex_policy_test.go +++ b/backend/internal/service/setting_service_codex_policy_test.go @@ -202,3 +202,34 @@ func TestMigrateCodexBodyFingerprintToSignals(t *testing.T) { require.Equal(t, openai.DefaultEngineFingerprintSignalsJSON(), repo.values[SettingKeyCodexCLIOnlyEngineFingerprintSignals]) }) } + +func TestMigrateGrokDefaultTextModel(t *testing.T) { + t.Run("upgrades legacy built-in default", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyGrokDefaultTextModel: "grok-4.5", + }} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateGrokDefaultTextModel(context.Background())) + require.Equal(t, "grok-4.6", repo.values[SettingKeyGrokDefaultTextModel]) + require.Equal(t, "grok-4.6", repo.sets[SettingKeyGrokDefaultTextModel]) + }) + + t.Run("does not overwrite an explicit model", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyGrokDefaultTextModel: "grok-4.3", + }} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateGrokDefaultTextModel(context.Background())) + require.Equal(t, "grok-4.3", repo.values[SettingKeyGrokDefaultTextModel]) + _, wrote := repo.sets[SettingKeyGrokDefaultTextModel] + require.False(t, wrote) + }) + + t.Run("missing setting is left for normal defaults", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{}} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateGrokDefaultTextModel(context.Background())) + _, wrote := repo.sets[SettingKeyGrokDefaultTextModel] + require.False(t, wrote) + }) +} diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 67bab243ca..3fa0d40426 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -116,6 +116,29 @@ func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing } } +func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) { + // 缺省(迁移插入 'false' / 老库无行)一律不展示。 + missingRuntime := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, missingRuntime.ShowQuota) + missingPublic, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}). + GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, missingPublic.ChannelMonitorShowQuota) + + // 仅字面 "true" 视为开启;其余值(含异常值)fail-closed。 + runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorShowQuota: "true", + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.True(t, runtime.ShowQuota) + + for _, value := range []string{"false", "TRUE", "1", "yes", "on", "garbage"} { + rt := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorShowQuota: value, + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, rt.ShowQuota, "value=%q", value) + } +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index f77a914fd7..7a421fde75 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -417,12 +417,13 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) } updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput) + updates[SettingKeyChannelMonitorShowQuota] = strconv.FormatBool(settings.ChannelMonitorShowQuota) // Grok model mapping policy if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { updates[SettingKeyGrokDefaultTextModel] = v } else { - updates[SettingKeyGrokDefaultTextModel] = "grok-4.5" + updates[SettingKeyGrokDefaultTextModel] = "grok-4.6" } updates[SettingKeyGrokCrossClientModelMapEnabled] = strconv.FormatBool(settings.GrokCrossClientModelMapEnabled) updates[SettingKeyGrokDefaultBaseURLMode] = normalizeGrokDefaultBaseURLMode(settings.GrokDefaultBaseURLMode) @@ -434,6 +435,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled) updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth) updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription + updates[SettingKeyPluginManagementEnabled] = strconv.FormatBool(settings.PluginManagementEnabled) // Affiliate (邀请返利) feature switch updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8580f0c8fb..25d2b313e1 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -201,6 +201,7 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -211,9 +212,10 @@ type SystemSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` // Model Plaza feature (public group/model pricing showcase) - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - ModelPlazaDescription string `json:"model_plaza_description"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaDescription string `json:"model_plaza_description"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` // Claude Code version check MinClaudeCodeVersion string @@ -378,6 +380,7 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -388,8 +391,9 @@ type PublicSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` // Model Plaza feature (public group/model pricing showcase) - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` // Affiliate (邀请返利) feature toggle AffiliateEnabled bool `json:"affiliate_enabled"` @@ -561,6 +565,23 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +// OpenAIAPIKeyHealthBreakerSettings controls cross-instance failure counting for OpenAI pool API keys. +type OpenAIAPIKeyHealthBreakerSettings struct { + Enabled bool `json:"enabled"` + WindowMinutes int `json:"window_minutes"` + FailureThreshold int `json:"failure_threshold"` + CooldownMinutes int `json:"cooldown_minutes"` +} + +func DefaultOpenAIAPIKeyHealthBreakerSettings() *OpenAIAPIKeyHealthBreakerSettings { + return &OpenAIAPIKeyHealthBreakerSettings{ + Enabled: false, + WindowMinutes: 2, + FailureThreshold: 10, + CooldownMinutes: 5, + } +} + // DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { return &OverloadCooldownSettings{ diff --git a/backend/internal/service/subscription_expiry_service.go b/backend/internal/service/subscription_expiry_service.go index c93c763f02..e27da3661d 100644 --- a/backend/internal/service/subscription_expiry_service.go +++ b/backend/internal/service/subscription_expiry_service.go @@ -15,6 +15,7 @@ import ( ) const ( + subscriptionExpiryReminderSMTPWarningInterval = time.Minute // subscriptionExpiryReminderLeaderLockKey gates the per-cycle reminder scan so // that only one instance walks all active subscriptions and sends reminder // emails, avoiding redundant full scans and duplicate emails. @@ -37,6 +38,9 @@ type SubscriptionExpiryService struct { lockCache LeaderLockCache db *sql.DB instanceID string + + smtpWarningMu sync.Mutex + lastSMTPWarning time.Time } func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService { @@ -121,6 +125,9 @@ func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) { if !s.expiryReminderEnabled(ctx) { return } + if !s.smtpConfigured(ctx) { + return + } // Multi-instance guard: only the leader walks every active subscription and // sends reminders, avoiding N× full scans and duplicate reminder emails. @@ -159,6 +166,28 @@ func (s *SubscriptionExpiryService) expiryReminderEnabled(ctx context.Context) b return !isFalseSettingValue(value) } +func (s *SubscriptionExpiryService) smtpConfigured(ctx context.Context) bool { + if s == nil || s.notificationEmailService == nil || s.notificationEmailService.emailService == nil { + return false + } + _, err := s.notificationEmailService.emailService.GetSMTPConfig(ctx) + if err == nil { + return true + } + if errors.Is(err, ErrEmailNotConfigured) { + s.smtpWarningMu.Lock() + defer s.smtpWarningMu.Unlock() + now := time.Now() + if s.lastSMTPWarning.IsZero() || now.Sub(s.lastSMTPWarning) >= subscriptionExpiryReminderSMTPWarningInterval { + log.Printf("[SubscriptionExpiry] SMTP is not configured; skipping expiry reminders") + s.lastSMTPWarning = now + } + return false + } + log.Printf("[SubscriptionExpiry] Read SMTP configuration failed; skipping expiry reminders: %v", err) + return false +} + func (s *SubscriptionExpiryService) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) { if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" { return diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go index 74b82e56c3..decb816e84 100644 --- a/backend/internal/service/subscription_expiry_service_test.go +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -1,8 +1,10 @@ package service import ( + "bytes" "context" "errors" + "log" "testing" "time" @@ -116,8 +118,9 @@ func (r *subscriptionExpiryRepoStub) BatchUpdateExpiredStatus(context.Context) ( } type subscriptionExpirySettingRepoStub struct { - values map[string]string - err error + values map[string]string + err error + multiErr error } func (r *subscriptionExpirySettingRepoStub) Get(context.Context, string) (*Setting, error) { @@ -139,8 +142,17 @@ func (r *subscriptionExpirySettingRepoStub) Set(context.Context, string, string) return nil } -func (r *subscriptionExpirySettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { - return nil, nil +func (r *subscriptionExpirySettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + if r.multiErr != nil { + return nil, r.multiErr + } + values := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + values[key] = value + } + } + return values, nil } func (r *subscriptionExpirySettingRepoStub) SetMultiple(context.Context, map[string]string) error { @@ -182,3 +194,44 @@ func TestSubscriptionExpiryService_ExpiryReminderSettingReadErrorFailsClosed(t * require.False(t, svc.expiryReminderEnabled(context.Background())) } + +func TestSubscriptionExpiryService_MissingSMTPSkipsReminderScanAndLogsOncePerInterval(t *testing.T) { + repo := &subscriptionExpiryRepoStub{} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + emailService := NewEmailService(settingRepo, nil) + svc := NewSubscriptionExpiryService(repo, time.Minute) + svc.SetSettingRepository(settingRepo) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, emailService)) + + var logs bytes.Buffer + previousWriter := log.Writer() + previousFlags := log.Flags() + log.SetOutput(&logs) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousWriter) + log.SetFlags(previousFlags) + }) + + svc.sendExpiryReminders(context.Background()) + svc.sendExpiryReminders(context.Background()) + + require.Zero(t, repo.listCalls) + require.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("SMTP is not configured"))) +} + +func TestSubscriptionExpiryService_SMTPConfigReadErrorSkipsReminderScan(t *testing.T) { + repo := &subscriptionExpiryRepoStub{} + settingRepo := &subscriptionExpirySettingRepoStub{ + values: map[string]string{}, + multiErr: errors.New("db down"), + } + emailService := NewEmailService(settingRepo, nil) + svc := NewSubscriptionExpiryService(repo, time.Minute) + svc.SetSettingRepository(settingRepo) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, emailService)) + + svc.sendExpiryReminders(context.Background()) + + require.Zero(t, repo.listCalls) +} diff --git a/backend/internal/service/temp_unsched.go b/backend/internal/service/temp_unsched.go index 06ae726df9..92b7da3f72 100644 --- a/backend/internal/service/temp_unsched.go +++ b/backend/internal/service/temp_unsched.go @@ -25,6 +25,12 @@ type TempUnschedCache interface { DeleteTempUnsched(ctx context.Context, accountID int64) error } +// OpenAIAPIKeyHealthCache is an optional TempUnschedCache extension used to +// aggregate pool API-key failures across gateway instances. +type OpenAIAPIKeyHealthCache interface { + RecordOpenAIAPIKeyHealthFailure(ctx context.Context, accountID int64, windowMinutes, threshold int) (count int64, tripped bool, err error) +} + // TimeoutCounterCache 超时计数器缓存接口 type TimeoutCounterCache interface { // IncrementTimeoutCount 增加账户的超时计数,返回当前计数值 diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index d66cd905dc..7255464c03 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -597,6 +597,9 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } baseURL := account.GetCredential("base_url") + if account.IsCNProvider() && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolChatCompletions) + } if account.Platform == PlatformOpenAI { if baseURL == "" { // 保持官方语义:OpenAI 账号无自定义 base 时探官方域(404 → unsupported)。 @@ -965,7 +968,9 @@ func decodeUpstreamBillingProbeSnapshot(extra map[string]any) *UpstreamBillingPr // IsUpstreamBillingProbeIdentity reports whether an account identity may opt // in to the upstream billing probe. `/v1/sub2api/billing` is a key-scoped -// sub2api convention shared by the five supported API-key platforms. +// sub2api convention shared by the supported API-key platforms (including the +// CN providers, whose official-domain accounts are short-circuited to +// "unsupported" by upstreamBillingProbeTargetIsOfficialAPI). // Non-sub2api upstreams return 404 and the snapshot records "unsupported". // Only AccountTypeAPIKey is in scope. OAuth/Bedrock hold no static API key to // present at all; AccountTypeUpstream (antigravity relay accounts) does carry @@ -978,7 +983,8 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { return false } switch platform { - case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok: + case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek: return true default: return false @@ -1002,6 +1008,9 @@ func isUpstreamBillingProbeAccount(account *Account) bool { // ollama.com is a first-class configuration here (Ollama Cloud accounts are // platform openai/anthropic with base_url https://ollama.com/v1), and it is // an official provider API just like the rest, so it belongs on this list. +// CN provider domains (moonshot.cn / kimi.com / bigmodel.cn / deepseek.com) +// serve the same role: official APIs that can never host /v1/sub2api/billing, +// so their accounts short-circuit to "unsupported" without a request. var upstreamBillingProbeOfficialAPIDomains = []string{ "anthropic.com", "googleapis.com", @@ -1009,6 +1018,10 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "grok.com", "openai.com", "ollama.com", + "moonshot.cn", + "kimi.com", + "bigmodel.cn", + "deepseek.com", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 018f2b7198..6690a80c15 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -11,11 +11,12 @@ import ( "github.com/stretchr/testify/require" ) -// 探测资格:/v1/sub2api/billing 是 key 级端点,五个 -// 受支持平台的 API-key 账号都可开启探测;OAuth/Bedrock 无静态 Key 仍不合格。 +// 探测资格:/v1/sub2api/billing 是 key 级端点,全部 +// 受支持平台(含国产供应商)的 API-key 账号都可开启探测;OAuth/Bedrock 无静态 Key 仍不合格。 func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, + PlatformKimi, PlatformZhipu, PlatformDeepseek, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) @@ -124,6 +125,14 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te {PlatformAnthropic, "https://ollama.com/v1"}, {PlatformAnthropic, "https://ollama.com"}, {PlatformAnthropic, "https://www.ollama.com/v1"}, + // 国产供应商官方域(含各协议端点)同样是官方 API,创建即开探测也不发请求。 + {PlatformKimi, "https://api.moonshot.cn/v1"}, + {PlatformKimi, "https://api.moonshot.cn/anthropic"}, + {PlatformKimi, "https://api.kimi.com/coding"}, + {PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4"}, + {PlatformZhipu, "https://open.bigmodel.cn/api/anthropic"}, + {PlatformDeepseek, "https://api.deepseek.com"}, + {PlatformDeepseek, "https://api.deepseek.com/anthropic"}, } for i, tc := range cases { account := &Account{ @@ -160,6 +169,11 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.com:443/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://www.ollama.com/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("HTTPS://OLLAMA.COM./v1")) + // 国产供应商官方域及子域。 + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.moonshot.cn/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.kimi.com/coding/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://open.bigmodel.cn/api/anthropic")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic")) // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) @@ -168,6 +182,11 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notollama.com/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.com.evil.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notmoonshot.cn/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://moonshot.cn.evil.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://kimi.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notbigmodel.cn")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://deepseek.example.com")) } // OpenAI 语义保持不变:无自定义 base 时仍探官方域,且沿用 openai 传输画像。 diff --git a/backend/internal/service/upstream_billing_probe_test.go b/backend/internal/service/upstream_billing_probe_test.go index e032e9aa16..9a5f774f4f 100644 --- a/backend/internal/service/upstream_billing_probe_test.go +++ b/backend/internal/service/upstream_billing_probe_test.go @@ -340,6 +340,38 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { require.Equal(t, snapshot.Status, persisted.Status) } +func TestUpstreamBillingProbeAdaptiveCNUsesChatProtocolBaseURL(t *testing.T) { + account := &Account{ + ID: 18, + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://legacy-relay.example/v1", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://chat-relay.example/v1", + }, + }, + Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: upstreamBillingProbeValidBody(), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Equal(t, "https://chat-relay.example/v1/sub2api/billing", upstream.lastReq.URL.String()) +} + func TestUpstreamBillingProbeSyncsResolvedRateForAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 9a4e4311b0..d3d88c4269 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -3,18 +3,127 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" + "log/slog" "net/http" + "net/url" "sort" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" ) -const upstreamModelsBodyLimit int64 = 8 << 20 +const ( + upstreamModelsBodyLimit int64 = 8 << 20 + modelsDevRegistryURL = "https://models.dev/api.json" + modelsDevRegistryTTL = 6 * time.Hour + UpstreamModelMetadataExtraKey = "upstream_model_metadata" + UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" +) + +type UpstreamModelMetadata struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + ContextWindow int64 `json:"context_window,omitempty"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` +} + +type UpstreamModelMetadataSnapshot struct { + Source string `json:"source"` + SyncedAt string `json:"synced_at"` + Models map[string]UpstreamModelMetadata `json:"models"` +} + +type UpstreamModelCatalog struct { + Models []string `json:"models"` + Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"` + Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"` +} + +type UpstreamModelSyncWarning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type modelsDevProvider struct { + ID string `json:"id"` + Name string `json:"name"` + API string `json:"api"` + Models map[string]modelsDevModel `json:"models"` +} + +type modelsDevModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Reasoning *bool `json:"reasoning"` + ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` + Modalities modelsDevModalities `json:"modalities"` + Limit modelsDevLimit `json:"limit"` +} + +type modelsDevReasoningOption struct { + Type string `json:"type"` + Values []any `json:"values"` +} + +type modelsDevModalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} + +type modelsDevLimit struct { + Context int64 `json:"context"` + Output int64 `json:"output"` +} + +func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) { + if a == nil { + return + } + if a.Extra == nil { + a.Extra = make(map[string]any) + } + a.Extra[UpstreamModelMetadataExtraKey] = snapshot +} + +func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot { + if a == nil || a.Extra == nil { + return nil + } + raw, ok := a.Extra[UpstreamModelMetadataExtraKey] + if !ok || raw == nil { + return nil + } + body, err := json.Marshal(raw) + if err != nil { + return nil + } + var snapshot UpstreamModelMetadataSnapshot + if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 { + return nil + } + return &snapshot +} + +func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) { + snapshot := a.GetUpstreamModelMetadataSnapshot() + if snapshot == nil { + return UpstreamModelMetadata{}, false + } + metadata, ok := snapshot.Models[strings.TrimSpace(modelID)] + return metadata, ok +} // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping. type UpstreamModelSyncErrorKind string @@ -26,13 +135,16 @@ const ( UpstreamModelSyncErrorUnsupported UpstreamModelSyncErrorKind = "unsupported" // UpstreamModelSyncErrorUpstream means the configured upstream failed or returned an unusable response. UpstreamModelSyncErrorUpstream UpstreamModelSyncErrorKind = "upstream" + // UpstreamModelSyncErrorInternal means local persistence or service state failed after a valid upstream response. + UpstreamModelSyncErrorInternal UpstreamModelSyncErrorKind = "internal" ) // UpstreamModelSyncError keeps internal failure details wrapped while exposing a safe client message. type UpstreamModelSyncError struct { - Kind UpstreamModelSyncErrorKind - Message string - Err error + Kind UpstreamModelSyncErrorKind + Message string + StatusCode int + Err error } func (e *UpstreamModelSyncError) Error() string { @@ -72,48 +184,432 @@ func newUpstreamModelSyncUpstreamError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err} } -// FetchUpstreamSupportedModels fetches the live model list from the account's upstream API format. +func newUpstreamModelSyncInternalError(message string, err error) error { + return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorInternal, Message: message, Err: err} +} + +// FetchUpstreamSupportedModels fetches only live model IDs. The admin sync path +// uses SyncUpstreamModelCatalog so capability metadata can also be persisted. func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, account *Account) ([]string, error) { + models, _, err := s.fetchUpstreamModelList(ctx, account) + return models, err +} + +// SyncUpstreamModelCatalog fetches the account's live model list, enriches +// missing capability fields from the provider registry used by the upstream, +// and persists a normalized account snapshot when metadata is available. +func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { + models, body, err := s.fetchUpstreamModelList(ctx, account) + if err != nil { + configuredModels := configuredUpstreamModelsForCapabilitySync(account) + if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 { + return nil, err + } + models = configuredModels + body = nil + slog.Info("upstream model list endpoint unavailable; using configured models for capability sync", + "account_id", upstreamModelSyncAccountID(account), + "platform", upstreamModelSyncPlatform(account), + "status_code", upstreamModelSyncStatusCode(err), + "model_count", len(models), + ) + } + catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)} + if len(body) > 0 { + _, directMetadata, parseErr := extractUpstreamModelCatalog(body, account != nil && account.IsGrok()) + if parseErr == nil { + catalog.Metadata = directMetadata + } + } + + source := "upstream" + metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata) + if metadataIncomplete { + if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil { + for modelID, fallback := range registryMetadata { + current := catalog.Metadata[modelID] + merged, changed := mergeUpstreamModelMetadata(current, fallback) + catalog.Metadata[modelID] = merged + if changed { + source = "models.dev" + } + } + } else { + slog.Warn("upstream model capability metadata enrichment failed", + "account_id", upstreamModelSyncAccountID(account), + "platform", upstreamModelSyncPlatform(account), + "error", registryErr, + ) + } + } + + if upstreamCatalogNeedsRegistry(models, catalog.Metadata) { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }) + return catalog, nil + } + if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil { + return catalog, nil + } + snapshot := UpstreamModelMetadataSnapshot{ + Source: source, + SyncedAt: time.Now().UTC().Format(time.RFC3339), + Models: catalog.Metadata, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { + return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) + } + account.SetUpstreamModelMetadataSnapshot(snapshot) + return catalog, nil +} + +func upstreamModelSyncStatusCode(err error) int { + var syncErr *UpstreamModelSyncError + if errors.As(err, &syncErr) { + return syncErr.StatusCode + } + return 0 +} + +func upstreamModelListEndpointUnsupported(err error) bool { + statusCode := upstreamModelSyncStatusCode(err) + return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed +} + +func configuredUpstreamModelsForCapabilitySync(account *Account) []string { + if account == nil { + return nil + } + models := make([]string, 0) + for _, mappedModel := range account.GetModelMapping() { + mappedModel = strings.TrimSpace(mappedModel) + if mappedModel == "" || strings.Contains(mappedModel, "*") { + continue + } + models = append(models, mappedModel) + } + return dedupeAndSortModelIDs(models) +} + +func upstreamModelSyncAccountID(account *Account) int64 { + if account == nil { + return 0 + } + return account.ID +} + +func upstreamModelSyncPlatform(account *Account) string { + if account == nil { + return "" + } + return account.Platform +} + +func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamModelMetadata) bool { + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + model, ok := metadata[modelID] + if !ok || !upstreamModelMetadataIsUseful(model) { + return true + } + if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 { + return true + } + if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 { + return true + } + } + return false +} + +func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { + return strings.TrimSpace(metadata.DisplayName) != "" || + strings.TrimSpace(metadata.Description) != "" || + metadata.Reasoning != nil || + len(metadata.SupportedReasoningLevels) > 0 || + len(metadata.InputModalities) > 0 || + metadata.ContextWindow > 0 || + metadata.MaxOutputTokens > 0 +} + +func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { + merged := primary + changed := false + if strings.TrimSpace(merged.ID) == "" && strings.TrimSpace(fallback.ID) != "" { + merged.ID = strings.TrimSpace(fallback.ID) + changed = true + } + if strings.TrimSpace(merged.DisplayName) == "" && strings.TrimSpace(fallback.DisplayName) != "" { + merged.DisplayName = strings.TrimSpace(fallback.DisplayName) + changed = true + } + if strings.TrimSpace(merged.Description) == "" && strings.TrimSpace(fallback.Description) != "" { + merged.Description = strings.TrimSpace(fallback.Description) + changed = true + } + if merged.Reasoning == nil && fallback.Reasoning != nil { + reasoning := *fallback.Reasoning + merged.Reasoning = &reasoning + changed = true + } + if strings.TrimSpace(merged.DefaultReasoningLevel) == "" && strings.TrimSpace(fallback.DefaultReasoningLevel) != "" { + merged.DefaultReasoningLevel = strings.TrimSpace(fallback.DefaultReasoningLevel) + changed = true + } + if len(merged.SupportedReasoningLevels) == 0 && len(fallback.SupportedReasoningLevels) > 0 { + merged.SupportedReasoningLevels = append([]string(nil), fallback.SupportedReasoningLevels...) + changed = true + } + if len(merged.InputModalities) == 0 && len(fallback.InputModalities) > 0 { + merged.InputModalities = append([]string(nil), fallback.InputModalities...) + changed = true + } + if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 { + merged.ContextWindow = fallback.ContextWindow + changed = true + } + if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 { + merged.MaxOutputTokens = fallback.MaxOutputTokens + changed = true + } + return merged, changed +} + +func (s *AccountTestService) fetchModelsDevMetadata( + ctx context.Context, + account *Account, + modelIDs []string, +) (map[string]UpstreamModelMetadata, error) { + if s == nil || s.httpUpstream == nil || account == nil { + return nil, fmt.Errorf("model metadata registry is not configured") + } + registry, err := s.fetchModelsDevRegistry(ctx, account) + if err != nil { + return nil, err + } + provider, ok := matchModelsDevProvider(registry, upstreamModelRegistryBaseURL(account)) + if !ok { + return nil, fmt.Errorf("no model metadata provider matches account base URL") + } + + metadata := make(map[string]UpstreamModelMetadata) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + model, found := provider.Models[modelID] + if !found { + for candidateID, candidate := range provider.Models { + if strings.EqualFold(strings.TrimSpace(candidateID), modelID) || strings.EqualFold(strings.TrimSpace(candidate.ID), modelID) { + model = candidate + found = true + break + } + } + } + if !found { + continue + } + entry := upstreamMetadataFromModelsDevModel(modelID, model) + if upstreamModelMetadataIsUseful(entry) { + metadata[modelID] = entry + } + } + return metadata, nil +} + +func (s *AccountTestService) fetchModelsDevRegistry(ctx context.Context, account *Account) (map[string]modelsDevProvider, error) { + now := time.Now() + s.modelMetadataRegistryMu.Lock() + if len(s.modelMetadataRegistry) > 0 && now.Sub(s.modelMetadataRegistryAt) < modelsDevRegistryTTL { + cached := s.modelMetadataRegistry + s.modelMetadataRegistryMu.Unlock() + return cached, nil + } + s.modelMetadataRegistryMu.Unlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevRegistryURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("model metadata registry returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > upstreamModelsBodyLimit { + return nil, fmt.Errorf("model metadata registry response exceeds %d bytes", upstreamModelsBodyLimit) + } + var registry map[string]modelsDevProvider + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("parse model metadata registry: %w", err) + } + if len(registry) == 0 { + return nil, fmt.Errorf("model metadata registry is empty") + } + + s.modelMetadataRegistryMu.Lock() + s.modelMetadataRegistry = registry + s.modelMetadataRegistryAt = now + s.modelMetadataRegistryMu.Unlock() + return registry, nil +} + +func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata { + levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions) + reasoning := model.Reasoning + if reasoning == nil && len(levels) > 0 { + inferred := true + reasoning = &inferred + } + metadata := UpstreamModelMetadata{ + ID: strings.TrimSpace(modelID), + DisplayName: strings.TrimSpace(model.Name), + Description: strings.TrimSpace(model.Description), + Reasoning: reasoning, + SupportedReasoningLevels: levels, + InputModalities: normalizeCodexInputModalities(model.Modalities.Input), + ContextWindow: model.Limit.Context, + MaxOutputTokens: model.Limit.Output, + } + if len(levels) > 0 { + metadata.DefaultReasoningLevel = levels[0] + } + if strings.TrimSpace(model.ID) != "" { + metadata.ID = strings.TrimSpace(model.ID) + } + return metadata +} + +func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string { + levels := make([]string, 0) + for _, option := range options { + if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") { + continue + } + for _, value := range option.Values { + if value == nil { + levels = append(levels, "none") + continue + } + if effort, ok := value.(string); ok { + levels = append(levels, effort) + } + } + } + return normalizeReasoningLevels(levels) +} + +func upstreamModelRegistryBaseURL(account *Account) string { + if account == nil { + return "" + } + switch { + case account.IsOpenAI() || account.IsCNProvider(): + return account.GetOpenAIFormatBaseURL() + case account.IsGrok(): + return account.GetGrokBaseURL() + case account.IsGemini(): + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + case account.IsAnthropic(): + return account.GetBaseURL() + case account.Platform == PlatformAntigravity: + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + default: + return strings.TrimSpace(account.GetCredential("base_url")) + } +} + +func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) + if accountBaseURL == "" { + return modelsDevProvider{}, false + } + var best modelsDevProvider + bestScore := -1 + for _, provider := range registry { + providerBaseURL := normalizeModelRegistryBaseURL(provider.API) + if providerBaseURL == "" { + continue + } + if accountBaseURL != providerBaseURL && + !strings.HasPrefix(accountBaseURL, providerBaseURL+"/") && + !strings.HasPrefix(providerBaseURL, accountBaseURL+"/") { + continue + } + if len(providerBaseURL) > bestScore { + best = provider + bestScore = len(providerBaseURL) + } + } + return best, bestScore >= 0 +} + +func normalizeModelRegistryBaseURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + path := strings.TrimRight(parsed.Path, "/") + if strings.HasSuffix(strings.ToLower(path), "/models") { + path = strings.TrimRight(path[:len(path)-len("/models")], "/") + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path +} + +func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) { if s == nil { - return nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) } if account == nil { - return nil, newUpstreamModelSyncConfigError("Account is required", nil) + return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil) } if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey { - return s.fetchAntigravityOAuthUpstreamModels(ctx, account) + models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account) + return models, nil, err } if s.httpUpstream == nil { - return nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) } req, err := s.buildUpstreamModelsRequest(ctx, account) if err != nil { - return nil, err + return nil, nil, err } proxyURL := upstreamModelsProxyURL(account) resp, err := s.doUpstreamModelsRequest(req, proxyURL, account) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) } defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) } - if int64(len(body)) > upstreamModelsBodyLimit { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit)) + if int64(len(body)) > bodyLimit { + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, newUpstreamModelSyncUpstreamError( - fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), - fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), - ) + return nil, nil, &UpstreamModelSyncError{ + Kind: UpstreamModelSyncErrorUpstream, + Message: fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), + StatusCode: resp.StatusCode, + Err: fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), + } } extractModels := extractUpstreamModelIDs @@ -122,13 +618,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a } models, err := extractModels(body) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) } if len(models) == 0 { - return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) } - return models, nil + return models, body, nil } func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { @@ -137,7 +633,8 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc return s.buildAntigravityAPIKeyModelsRequest(ctx, account) case account.IsGrok(): return s.buildGrokUpstreamModelsRequest(ctx, account) - case account.IsOpenAI(): + case account.IsOpenAI() || account.IsCNProvider(): + // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。 return s.buildOpenAIUpstreamModelsRequest(ctx, account) case account.IsGemini(): return s.buildGeminiUpstreamModelsRequest(ctx, account) @@ -342,17 +839,22 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con } func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { + if account.IsOpenAIOAuth() { + return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) + } if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, ) } - apiKey := strings.TrimSpace(account.GetOpenAIApiKey()) + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return nil, newUpstreamModelSyncConfigError("No OpenAI API key is available", nil) } - baseURL := account.GetOpenAIBaseURL() + // 协议感知:Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,模型 + // 列表同步需使用 OpenAI 格式 base(供应商 × 模式默认)。 + baseURL := account.GetOpenAIFormatBaseURL() if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com" } @@ -372,6 +874,68 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex return req, nil } +// buildOpenAIOAuthUpstreamModelsRequest uses ChatGPT's Codex model manifest. +// OAuth subscriptions do not expose the public Platform API /v1/models endpoint, +// so treating them like API-key accounts makes the admin sync button fail locally. +func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { + credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Failed to resolve OpenAI account credentials", err) + } + if !credentialAccount.IsOpenAIOAuth() { + return nil, newUpstreamModelSyncUnsupportedError( + fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", credentialAccount.Type), nil, + ) + } + + modelsURL, err := buildCodexModelsManifestURL( + chatgptCodexModelsURL, + false, + CodexCanonicalClientVersion(), + ) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list URL", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list request", err) + } + + if credentialAccount.IsOpenAIAgentIdentity() { + authHeaders, authErr := buildAgentIdentityAuthenticationHeaders( + ctx, + s.accountRepo, + s.agentIdentityWS, + &s.agentIdentityTaskMu, + credentialAccount, + ) + if authErr != nil { + return nil, newUpstreamModelSyncUpstreamError("Failed to build OpenAI Agent Identity authentication", authErr) + } + for key, values := range authHeaders { + for _, value := range values { + req.Header.Add(key, value) + } + } + } else { + accessToken := strings.TrimSpace(credentialAccount.GetOpenAIAccessToken()) + if accessToken == "" { + return nil, newUpstreamModelSyncConfigError("No OpenAI access token is available", nil) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + identity := resolveCodexOutboundIdentity(credentialAccount.GetOpenAIUserAgent()) + req.Header.Set("Accept", "application/json") + req.Header.Set("Originator", identity.originator) + req.Header.Set("User-Agent", identity.userAgent) + req.Header.Set("Version", identity.version) + setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) + credentialAccount.ApplyHeaderOverrides(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + return req, nil +} + func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) if strings.TrimSpace(baseURL) == "" { @@ -438,7 +1002,12 @@ func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err) } - modelsResp, _, err := client.FetchAvailableModels(ctx, accessToken, strings.TrimSpace(account.GetCredential("project_id"))) + modelsResp, _, err := client.FetchAvailableModels( + ctx, + accessToken, + strings.TrimSpace(account.GetCredential("project_id")), + resolveModelsListReadLimit(s.cfg), + ) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err) } @@ -495,6 +1064,7 @@ func buildGeminiModelsURL(base string) string { type upstreamModelEntry struct { ID string `json:"id"` + Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` @@ -504,12 +1074,29 @@ type upstreamModelEntry struct { type upstreamModelEntryMetadata struct { ID string `json:"id"` + Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` Name string `json:"name"` } +type upstreamModelCapabilityEntry struct { + upstreamModelEntry + DisplayName string `json:"display_name"` + Description string `json:"description"` + Reasoning *bool `json:"reasoning"` + DefaultReasoningLevel string `json:"default_reasoning_level"` + SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"` + ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` + InputModalities []string `json:"input_modalities"` + Modalities modelsDevModalities `json:"modalities"` + ContextWindow int64 `json:"context_window"` + MaxContextWindow int64 `json:"max_context_window"` + MaxOutputTokens int64 `json:"max_output_tokens"` + Limit modelsDevLimit `json:"limit"` +} + func extractUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, upstreamModelEntryID) } @@ -518,6 +1105,166 @@ func extractGrokUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, grokUpstreamModelEntryID) } +func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]UpstreamModelMetadata, error) { + entries, err := extractUpstreamModelRawEntries(body) + if err != nil { + return nil, nil, err + } + selectID := upstreamModelEntryID + if grok { + selectID = grokUpstreamModelEntryID + } + + models := make([]string, 0, len(entries)) + metadata := make(map[string]UpstreamModelMetadata) + for _, raw := range entries { + var capability upstreamModelCapabilityEntry + if err := json.Unmarshal(raw, &capability); err != nil { + continue + } + modelID := strings.TrimSpace(selectID(capability.upstreamModelEntry)) + if modelID == "" { + continue + } + models = append(models, modelID) + entry := upstreamMetadataFromCapabilityEntry(modelID, capability) + if upstreamModelMetadataIsUseful(entry) { + metadata[modelID] = entry + } + } + return dedupeAndSortModelIDs(models), metadata, nil +} + +func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) { + var response struct { + Data []json.RawMessage `json:"data"` + Models []json.RawMessage `json:"models"` + } + if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) { + entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models)) + entries = append(entries, response.Data...) + entries = append(entries, response.Models...) + return entries, nil + } + var entries []json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("parse upstream model catalog: %w", err) + } + return entries, nil +} + +func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata { + levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels) + if len(levels) == 0 { + levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions) + } + reasoning := entry.Reasoning + if reasoning == nil && len(levels) > 0 { + inferred := len(levels) != 1 || levels[0] != "none" + reasoning = &inferred + } + modalities := entry.InputModalities + if len(modalities) == 0 { + modalities = entry.Modalities.Input + } + contextWindow := entry.ContextWindow + if contextWindow <= 0 { + contextWindow = entry.MaxContextWindow + } + if contextWindow <= 0 { + contextWindow = entry.Limit.Context + } + maxOutputTokens := entry.MaxOutputTokens + if maxOutputTokens <= 0 { + maxOutputTokens = entry.Limit.Output + } + defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel) + if defaultReasoningLevel == "" && len(levels) > 0 { + defaultReasoningLevel = levels[0] + } + displayName := strings.TrimSpace(entry.DisplayName) + if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID { + displayName = strings.TrimSpace(entry.Name) + } + return UpstreamModelMetadata{ + ID: modelID, + DisplayName: displayName, + Description: strings.TrimSpace(entry.Description), + Reasoning: reasoning, + DefaultReasoningLevel: defaultReasoningLevel, + SupportedReasoningLevels: levels, + InputModalities: normalizeCodexInputModalities(modalities), + ContextWindow: contextWindow, + MaxOutputTokens: maxOutputTokens, + } +} + +func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string { + levels := make([]string, 0, len(entries)) + for _, raw := range entries { + var effort string + if err := json.Unmarshal(raw, &effort); err == nil { + levels = append(levels, effort) + continue + } + var level struct { + Effort string `json:"effort"` + } + if err := json.Unmarshal(raw, &level); err == nil { + levels = append(levels, level.Effort) + } + } + return normalizeReasoningLevels(levels) +} + +func normalizeReasoningLevels(levels []string) []string { + seen := make(map[string]struct{}, len(levels)) + normalized := make([]string, 0, len(levels)) + for _, level := range levels { + level = normalizeReasoningLevel(level) + if level == "" { + continue + } + if _, exists := seen[level]; exists { + continue + } + seen[level] = struct{}{} + normalized = append(normalized, level) + } + return normalized +} + +func normalizeReasoningLevel(level string) string { + level = strings.ToLower(strings.TrimSpace(level)) + switch level { + case "off", "disabled": + return "none" + case "extra-high", "extra_high": + return "xhigh" + case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra": + return level + default: + return "" + } +} + +func normalizeCodexInputModalities(modalities []string) []string { + seen := make(map[string]struct{}, len(modalities)) + normalized := make([]string, 0, len(modalities)) + for _, modality := range modalities { + modality = strings.ToLower(strings.TrimSpace(modality)) + if modality != "text" && modality != "image" { + continue + } + if _, exists := seen[modality]; exists { + continue + } + seen[modality] = struct{}{} + normalized = append(normalized, modality) + } + return normalized +} + func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) { var response struct { Data []upstreamModelEntry `json:"data"` @@ -558,6 +1305,9 @@ func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamMode func upstreamModelEntryID(entry upstreamModelEntry) string { modelID := strings.TrimSpace(entry.ID) + if modelID == "" { + modelID = strings.TrimSpace(entry.Slug) + } if modelID == "" { modelID = strings.TrimSpace(entry.Name) } @@ -570,6 +1320,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string { entry.ModelID, entry.ModelIDSnake, entry.ID, + entry.Slug, } if len(entry.Meta) > 0 { var meta upstreamModelEntryMetadata @@ -579,6 +1330,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string { meta.ModelID, meta.ModelIDSnake, meta.ID, + meta.Slug, meta.Name, ) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 031d09b574..b358406b8b 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -13,6 +14,28 @@ import ( "github.com/stretchr/testify/require" ) +type upstreamModelMetadataRepoStub struct { + AccountRepository + accountID int64 + updates map[string]any + err error +} + +func headerValuesEqualFold(header http.Header, name string) []string { + for key, values := range header { + if strings.EqualFold(key, name) { + return values + } + } + return nil +} + +func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + r.accountID = id + r.updates = updates + return r.err +} + func upstreamModelSyncTestConfig() *config.Config { return &config.Config{ Security: config.SecurityConfig{ @@ -140,6 +163,11 @@ func TestExtractUpstreamModelIDs(t *testing.T) { body: `{"data":[{"id":"canonical-id","model":"display-model"}]}`, want: []string{"canonical-id"}, }, + { + name: "codex manifest uses slug", + body: `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`, + want: []string{"gpt-5.5-codex", "gpt-5.6-sol"}, + }, } for _, tt := range tests { @@ -154,6 +182,53 @@ func TestExtractUpstreamModelIDs(t *testing.T) { } } +func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth(t *testing.T) { + svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 11, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "openai-oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + } + + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, chatgptCodexModelsURL, req.URL.Scheme+"://"+req.URL.Host+req.URL.Path) + require.NotEmpty(t, req.URL.Query().Get("client_version")) + require.Equal(t, "Bearer openai-oauth-token", req.Header.Get("Authorization")) + require.Equal(t, "chatgpt-account", req.Header.Get("chatgpt-account-id")) + require.NotEmpty(t, req.Header.Get("Originator")) + require.NotEmpty(t, req.Header.Get("User-Agent")) + require.NotEmpty(t, req.Header.Get("Version")) +} + +func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`)), + }} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + } + + models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ + ID: 12, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "openai-oauth-token", + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.6-sol"}, models) + require.Equal(t, "Bearer openai-oauth-token", upstream.lastReq.Header.Get("Authorization")) +} + func TestExtractGrokUpstreamModelIDs(t *testing.T) { t.Parallel() @@ -342,6 +417,409 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) { require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization")) } +// Scenario: ID-only 模型列表从 Models.dev 补齐能力。 +func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "opencode": { + "id": "opencode", + "name": "OpenCode Zen", + "api": "https://opencode.ai/zen/v1", + "models": { + "x-preview-f-free": { + "id": "x-preview-f-free", + "name": "Ox Alpha Free (Unlimited)", + "description": "Stealth reasoning model for coding, agentic tasks, and tool use", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","high","max"]}], + "modalities": {"input":["text","image","video"],"output":["text"]}, + "limit": {"context":1000000,"output":131072} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + } + account := &Account{ + ID: 91, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "opencode-key", + "base_url": "https://opencode.ai/zen/v1", + "header_override_enabled": true, + "header_overrides": map[string]any{ + "X-Custom-Account-Header": "account-secret", + }, + }, + } + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"x-preview-f-free"}, catalog.Models) + require.Len(t, upstream.requests, 2) + require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String()) + require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header")) + require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String()) + require.Empty(t, upstream.requests[1].Header.Get("Authorization")) + require.Empty(t, upstream.requests[1].Header.Get("x-api-key")) + require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header")) + + metadata := catalog.Metadata["x-preview-f-free"] + require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName) + require.NotNil(t, metadata.Reasoning) + require.True(t, *metadata.Reasoning) + require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) + require.Equal(t, int64(1_000_000), metadata.ContextWindow) + require.Equal(t, int64(131_072), metadata.MaxOutputTokens) + require.Equal(t, int64(91), repo.accountID) + + rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey] + require.True(t, ok) + encoded, err := json.Marshal(rawSnapshot) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Equal(t, "models.dev", snapshot.Source) + require.Equal(t, metadata, snapshot.Models["x-preview-f-free"]) +} + +// Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。 +func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "configured-provider": { + "id": "configured-provider", + "name": "Configured Provider", + "api": "https://provider.example/v1", + "models": { + "glm-5.3": { + "id": "glm-5.3", + "name": "GLM-5.3", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high"]}], + "modalities": {"input":["text"],"output":["text"]}, + "limit": {"context":1000000,"output":131072} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "key", + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{ + "public-glm": "glm-5.3", + "duplicate": "glm-5.3", + "wildcard": "glm-*", + "empty": "", + }, + }, + } + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"glm-5.3"}, catalog.Models) + require.Empty(t, catalog.Warnings) + require.Len(t, upstream.requests, 2) + require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String()) + require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String()) + metadata := catalog.Metadata["glm-5.3"] + require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text"}, metadata.InputModalities) + require.Equal(t, int64(1_000_000), metadata.ContextWindow) + require.NotNil(t, repo.updates) +} + +func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) { + tests := []struct { + name string + statusCode int + }{ + {name: "unauthorized", statusCode: http.StatusUnauthorized}, + {name: "rate limited", statusCode: http.StatusTooManyRequests}, + {name: "server error", statusCode: http.StatusBadGateway}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: tt.statusCode, + Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)), + }} + svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "key", + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"public-glm": "glm-5.3"}, + }, + }) + require.Error(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err)) + }) + } +} + +func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusMethodNotAllowed, + Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)), + }} + svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.Error(t, err) + require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err)) + require.Len(t, upstream.requests, 1) +} + +// Scenario: 完整上游模型清单优先保存能力。 +func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "slug":"custom-thinking-model", + "display_name":"Upstream Display", + "description":"Upstream description", + "default_reasoning_level":"high", + "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}], + "input_modalities":["text","image"], + "context_window":256000 + }]}`)), + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.NoError(t, err) + require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch") + metadata := catalog.Metadata["custom-thinking-model"] + require.Equal(t, "Upstream Display", metadata.DisplayName) + require.Equal(t, "high", metadata.DefaultReasoningLevel) + require.Equal(t, []string{"low", "high", "ultra"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) + require.Equal(t, int64(256_000), metadata.ContextWindow) +} + +// Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。 +func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[ + {"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000}, + {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000} + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[ + {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}, + {"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000} + ]}`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "key", + "base_url": "https://provider.example/v1", + }, + } + + first, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"kept-model", "old-model"}, first.Models) + + second, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"kept-model", "new-model"}, second.Models) + require.NotContains(t, second.Metadata, "old-model") + require.Contains(t, second.Metadata, "new-model") + + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.NotContains(t, snapshot.Models, "old-model") + require.Contains(t, snapshot.Models, "new-model") +} + +// Scenario: 上游明确声明无推理能力时保存 false。 +func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"company-coding-model", + "display_name":"Company Coding Model", + "reasoning":false, + "input_modalities":["text"], + "context_window":64000 + }]}`)), + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + metadata := catalog.Metadata["company-coding-model"] + require.NotNil(t, metadata.Reasoning) + require.False(t, *metadata.Reasoning) + require.Empty(t, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text"}, metadata.InputModalities) + require.Equal(t, int64(64_000), metadata.ContextWindow) + require.NotNil(t, repo.updates) +} + +func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"company-coding-model", + "reasoning":false, + "input_modalities":["text"], + "context_window":64000 + }]}`)), + }} + repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.Error(t, err) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind) +} + +// Scenario: 元数据源失败时保留已有快照。 +func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))}, + {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))}, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"}, + Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{ + "source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}}, + }}, + }) + require.NoError(t, err) + require.Equal(t, []string{"x-preview-f-free"}, catalog.Models) + require.Empty(t, catalog.Metadata) + require.Equal(t, []UpstreamModelSyncWarning{{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }}, catalog.Warnings) + require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot") +} + +func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"partially-described-model", + "display_name":"Partial Model" + }]}`))}, + {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))}, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{ + "source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{ + "reasoning": true, "supported_reasoning_levels": []any{"low", "high"}, + }}, + }}, + }) + require.NoError(t, err) + require.Equal(t, []string{"partially-described-model"}, catalog.Models) + require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName) + require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code) + require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot") +} + +func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)), + }} + cfg := upstreamModelSyncTestConfig() + cfg.Gateway.ModelsListReadMaxBytes = 8 + svc := &AccountTestService{httpUpstream: upstream, cfg: cfg} + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ + ID: 7, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "openai-key", + "base_url": "https://openai.example.com/v1", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "response exceeds 8 bytes") +} + func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/upstream_path_guard_test.go b/backend/internal/service/upstream_path_guard_test.go index 3ea88da285..f915ecd394 100644 --- a/backend/internal/service/upstream_path_guard_test.go +++ b/backend/internal/service/upstream_path_guard_test.go @@ -134,6 +134,7 @@ func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing for path, want := range map[string]string{ "/v1/responses": "", "/v1/responses/compact": "/compact", + "/v1/responses/input_tokens": "/input_tokens", "/responses/compact/": "/compact", "/backend-api/codex/responses/compact": "/compact", } { @@ -145,6 +146,43 @@ func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing } } +func TestIsOpenAIResponsesInputTokensRequestPath(t *testing.T) { + for _, path := range []string{"/v1/responses/input_tokens", "/responses/input_tokens", "/backend-api/codex/responses/input_tokens"} { + c := newResponsesSuffixTestContext(t, path) + require.True(t, IsOpenAIResponsesInputTokensRequestPath(c), "path=%s", path) + } + c := newResponsesSuffixTestContext(t, "/v1/responses/compact") + require.False(t, IsOpenAIResponsesInputTokensRequestPath(c)) +} + +func TestIsOpenAIResponsesCompactPathUsesLegacyEndpointShape(t *testing.T) { + legacyPaths := []string{ + "/v1/responses/compact", + "/v1/responses/compact/detail", + "/responses/compact/", + } + for _, path := range legacyPaths { + t.Run("legacy_"+path, func(t *testing.T) { + c := newResponsesSuffixTestContext(t, path) + require.True(t, IsOpenAIResponsesCompactPath(c)) + }) + } + + nonLegacyPaths := []string{ + "/v1/responses", + "/openai/v1/responses", + "/responses", + "/backend-api/codex/responses", + "/v1/responses/resp_123/cancel", + } + for _, path := range nonLegacyPaths { + t.Run("non_legacy_"+path, func(t *testing.T) { + c := newResponsesSuffixTestContext(t, path) + require.False(t, IsOpenAIResponsesCompactPath(c)) + }) + } +} + func TestAppendOpenAIResponsesRequestPathSuffixRefusesUnsafeSuffix(t *testing.T) { // 调用方漏了校验时,拼接函数本身也不得把不合规片段带进上游 URL。 require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/../../x")) diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index 81869c07d9..64cb048cc0 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -20,10 +20,23 @@ const ( // channel explicitly configured with billing_model_source = response_model, // where a conflict flag makes billing fall back to the baseline model // (see responseModelBillingDeclaration). +// +// The same observer also records the service tier the upstream reports having +// used (OpenAI service_tier, Anthropic usage.speed). The billable tier is +// resolved by resolvedOpenAIUpstreamServiceTierFromObserver (upstream echo +// first, outbound body tier as fallback); the upstream ResolveBillingServiceTier +// only-lowers path additionally audits downgrades at usage-record time. type upstreamResponseModelObserver struct { first string terminal string conflict bool + + // firstTier holds the first non-terminal tier declaration; it is discarded + // when later non-terminal declarations disagree. terminalTier comes from a + // terminal event and always wins. + firstTier string + firstTierConflict bool + terminalTier string } func (o *upstreamResponseModelObserver) Observe(model string, terminal bool) { @@ -57,17 +70,98 @@ func normalizeObservedUpstreamResponseModel(model string) string { } func (o *upstreamResponseModelObserver) ObserveOpenAI(payload []byte, eventType string) { - model := firstValidTrimmedGJSONModel(payload, "response.model", "model") - o.Observe(model, isUpstreamResponseModelTerminalEvent(eventType)) + model := firstValidTrimmedGJSONString(payload, "response.model", "model") + terminal := isUpstreamResponseModelTerminalEvent(eventType) + o.Observe(model, terminal) + // Every payload that declares a service tier also declares a model, so + // model-free delta frames skip the extra lookups entirely. + if model == "" { + return + } + // Non-terminal Responses API events echo the requested tier rather than the + // tier actually used. Only terminal events and untyped payloads (chat + // completions chunks, non-streaming bodies) report the processing tier. + if !terminal && strings.TrimSpace(eventType) != "" { + return + } + tier := normalizeObservedOpenAIServiceTier(firstValidTrimmedGJSONString(payload, "response.service_tier", "service_tier")) + o.ObserveServiceTier(tier, terminal) } func (o *upstreamResponseModelObserver) ObserveAnthropic(payload []byte) { - model := firstValidTrimmedGJSONModel(payload, "message.model", "model") + model := firstValidTrimmedGJSONString(payload, "message.model", "model") o.Observe(model, false) + // usage.speed travels with the message object (message_start in streams, + // the top-level body otherwise), i.e. only in payloads that declare a model. + if model == "" { + return + } + tier := normalizeObservedAnthropicSpeed(firstValidTrimmedGJSONString(payload, "message.usage.speed", "usage.speed")) + o.ObserveServiceTier(tier, false) +} + +// ObserveServiceTier records a tier declared by the upstream response. A +// terminal declaration always wins; non-terminal declarations are only trusted +// when they agree with each other. +func (o *upstreamResponseModelObserver) ObserveServiceTier(tier string, terminal bool) { + if o == nil || tier == "" { + return + } + if terminal { + o.terminalTier = tier + return + } + if o.firstTier == "" { + o.firstTier = tier + return + } + if o.firstTier != tier { + o.firstTierConflict = true + } +} + +// ServiceTier returns the tier the upstream reports having used, or "" when the +// response never declared one unambiguously. +func (o *upstreamResponseModelObserver) ServiceTier() string { + if o == nil { + return "" + } + if o.terminalTier != "" { + return o.terminalTier + } + if o.firstTierConflict { + return "" + } + return o.firstTier +} + +// normalizeObservedOpenAIServiceTier maps a tier reported by an OpenAI response +// onto the billing vocabulary. "auto" never describes a processing tier and +// unknown values are ignored rather than guessed at. +func normalizeObservedOpenAIServiceTier(raw string) string { + switch value := strings.ToLower(strings.TrimSpace(raw)); value { + case "priority", "fast": + return OpenAIFastTierPriority + case "default", "flex", "scale": + return value + default: + return "" + } +} + +// normalizeObservedAnthropicSpeed maps Anthropic usage.speed onto the billing +// vocabulary: "fast" is the billable fast-mode tier, "standard" the base rate. +func normalizeObservedAnthropicSpeed(raw string) string { + switch value := strings.ToLower(strings.TrimSpace(raw)); value { + case "fast", "standard": + return value + default: + return "" + } } func (o *upstreamResponseModelObserver) ObserveGemini(payload []byte) { - model := firstValidTrimmedGJSONModel( + model := firstValidTrimmedGJSONString( payload, "modelVersion", "response.modelVersion", @@ -120,17 +214,47 @@ func observedUpstreamResponseModelConflict(c *gin.Context) bool { return upstreamResponseModelObserverFromContext(c).Conflict() } +func observedUpstreamResponseServiceTier(c *gin.Context) string { + return upstreamResponseModelObserverFromContext(c).ServiceTier() +} + +// resolvedOpenAIUpstreamServiceTierFromObserver 返回计费/用量日志实际使用的 +// service tier: +// +// 1. observer 记录到的上游真实回显优先——只有上游实际给了 priority/fast 才按 +// Fast 计费;上游回显 default/flex/auto 等则如实采用并据此计费; +// 2. 上游未回显时,回退到「最终出站 body」里的 tier(经过 fast policy +// filter/force 之后),保证 policy filter 删掉字段后不再按原请求 Fast 计费。 +// +// HTTP→WS 等使用局部 observer 的路径必须把该 observer 传进来,不能只读 +// Gin context——局部 observer 不会自动写入 context。 +func resolvedOpenAIUpstreamServiceTierFromObserver(observer *upstreamResponseModelObserver, outboundBodyTier *string) *string { + if observer != nil { + if tier := strings.TrimSpace(observer.ServiceTier()); tier != "" { + return normalizeOpenAIServiceTier(tier) + } + } + return outboundBodyTier +} + +// resolvedOpenAIUpstreamServiceTier 读取 Gin context 上的 observer 后委托 +// resolvedOpenAIUpstreamServiceTierFromObserver。标准 HTTP 转发路径通过 +// beginUpstreamResponseModelObservation 把 observer 挂到 context;局部 +// observer 路径应直接调用 FromObserver。 +func resolvedOpenAIUpstreamServiceTier(c *gin.Context, outboundBodyTier *string) *string { + return resolvedOpenAIUpstreamServiceTierFromObserver(upstreamResponseModelObserverFromContext(c), outboundBodyTier) +} + func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) { if observer == nil || strings.TrimSpace(body) == "" { return } - forEachOpenAISSEDataPayload(body, func(payload []byte) { - eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + forEachOpenAISSEFrame(body, func(eventType string, payload []byte) { observer.ObserveOpenAI(payload, eventType) }) } -func firstValidTrimmedGJSONModel(payload []byte, paths ...string) string { +func firstValidTrimmedGJSONString(payload []byte, paths ...string) string { if len(payload) == 0 { return "" } @@ -139,14 +263,14 @@ func firstValidTrimmedGJSONModel(payload []byte, paths ...string) string { if !value.Exists() || value.Type != gjson.String { continue } - if model := strings.TrimSpace(value.String()); model != "" { + if text := strings.TrimSpace(value.String()); text != "" { // Validate only after finding a candidate. This avoids a full validation // pass on the common model-free delta path while still rejecting malformed - // payloads that appear to declare a model. + // payloads that appear to declare a value. if !gjson.ValidBytes(payload) { return "" } - return model + return text } } return "" @@ -167,10 +291,33 @@ func upstreamModelMismatch(sentModel, responseModel string) *bool { return nil } sentModel = strings.TrimSpace(sentModel) - mismatch := sentModel == "" || !strings.EqualFold(sentModel, responseModel) + mismatch := sentModel == "" || !upstreamModelsMatchForAudit(sentModel, responseModel) return &mismatch } +func upstreamModelsMatchForAudit(sentModel, responseModel string) bool { + if strings.EqualFold(sentModel, responseModel) { + return true + } + + // xAI reports the runtime build ID for these supported public aliases. + // Canonicalize only for mismatch auditing; keep the raw response model for + // observability and for the separate response-model billing safeguards. + sentGrokModel := canonicalGrokBuildRuntimeModel(sentModel) + return sentGrokModel != "" && sentGrokModel == canonicalGrokBuildRuntimeModel(responseModel) +} + +func canonicalGrokBuildRuntimeModel(model string) string { + switch strings.ToLower(strings.TrimSpace(model)) { + case "grok-4.5", "grok-4.5-latest", "grok-4.5-build": + return "grok-4.5-build" + case "grok-4.6", "grok-4.6-latest", "grok-4.6-build": + return "grok-4.6-build" + default: + return "" + } +} + func upstreamSentModel(requestedModel, upstreamModel string) string { sentModel := strings.TrimSpace(upstreamModel) if sentModel == "" { diff --git a/backend/internal/service/upstream_response_model_bench_test.go b/backend/internal/service/upstream_response_model_bench_test.go index 93d6854f72..ec56fe4b46 100644 --- a/backend/internal/service/upstream_response_model_bench_test.go +++ b/backend/internal/service/upstream_response_model_bench_test.go @@ -35,7 +35,7 @@ func BenchmarkUpstreamResponseModelOpenAI(b *testing.B) { b.Run("optimized", func(b *testing.B) { b.ReportAllocs() for b.Loop() { - upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONModel(tt.payload, "response.model", "model") + upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONString(tt.payload, "response.model", "model") } }) }) @@ -52,7 +52,7 @@ func BenchmarkUpstreamResponseModelAntigravityWrapper(b *testing.B) { b.Run("optimized", func(b *testing.B) { b.ReportAllocs() for b.Loop() { - upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONModel( + upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONString( upstreamResponseModelBenchmarkWrapper, "modelVersion", "response.modelVersion", diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go index a831e74e65..ea616d53ee 100644 --- a/backend/internal/service/upstream_response_model_test.go +++ b/backend/internal/service/upstream_response_model_test.go @@ -59,6 +59,77 @@ func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing. require.True(t, *mismatched) } +func TestUpstreamModelMismatchTreatsGrokBuildRuntimeIDsAsAliases(t *testing.T) { + tests := []struct { + name string + sentModel string + responseModel string + }{ + { + name: "issue 5634 grok 4.6", + sentModel: "grok-4.6", + responseModel: "grok-4.6-build", + }, + { + name: "grok 4.6 latest", + sentModel: "grok-4.6-latest", + responseModel: "grok-4.6-build", + }, + { + name: "issue 5647 grok 4.5 latest", + sentModel: "grok-4.5-latest", + responseModel: "grok-4.5-build", + }, + { + name: "grok 4.5 canonical", + sentModel: "grok-4.5", + responseModel: "GROK-4.5-BUILD", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel) + + require.NotNil(t, mismatch) + require.False(t, *mismatch) + }) + } +} + +func TestUpstreamModelMismatchDoesNotCollapseDifferentModels(t *testing.T) { + tests := []struct { + name string + sentModel string + responseModel string + }{ + { + name: "different grok versions", + sentModel: "grok-4.5", + responseModel: "grok-4.6-build", + }, + { + name: "unrelated build suffix", + sentModel: "gpt-5.5", + responseModel: "gpt-5.5-build", + }, + { + name: "different grok runtime", + sentModel: "grok-build-0.1", + responseModel: "grok-4.5-build", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel) + + require.NotNil(t, mismatch) + require.True(t, *mismatch) + }) + } +} + func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) { observer := &upstreamResponseModelObserver{} observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n") @@ -123,3 +194,79 @@ func TestUpstreamResponseModelObserverBoundsUntrustedModelName(t *testing.T) { require.Len(t, []rune(observer.Model()), upstreamResponseModelMaxLength) } + +func TestUpstreamResponseModelObserverServiceTierTerminalEventWins(t *testing.T) { + observer := &upstreamResponseModelObserver{} + + // response.created echoes the requested tier and must not count as a declaration. + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"priority"}}`), "response.created") + require.Equal(t, "", observer.ServiceTier()) + + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default","usage":{"input_tokens":1,"output_tokens":2}}}`), "response.completed") + require.Equal(t, "default", observer.ServiceTier()) + require.False(t, observer.Conflict(), "tier echo must not be reported as a model conflict") +} + +func TestUpstreamResponseModelObserverServiceTierUntypedPayloads(t *testing.T) { + t.Run("chat completions chunks agree", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5.4","service_tier":"default","choices":[]}`), "") + observer.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5.4","service_tier":"default","choices":[]}`), "") + require.Equal(t, "default", observer.ServiceTier()) + }) + + t.Run("disagreeing chunks are not trusted", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"model":"gpt-5.4","service_tier":"priority"}`), "") + observer.ObserveOpenAI([]byte(`{"model":"gpt-5.4","service_tier":"default"}`), "") + require.Equal(t, "", observer.ServiceTier()) + }) + + t.Run("non-stream body normalises fast and drops auto", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"id":"resp_1","object":"response","model":"gpt-5.6-sol","service_tier":"fast"}`), "") + require.Equal(t, "priority", observer.ServiceTier()) + + auto := &upstreamResponseModelObserver{} + auto.ObserveOpenAI([]byte(`{"id":"resp_2","object":"response","model":"gpt-5.6-sol","service_tier":"auto"}`), "") + require.Equal(t, "", auto.ServiceTier()) + }) + + t.Run("model-free deltas never declare a tier", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"type":"response.output_text.delta","delta":"hi","service_tier":"default"}`), "response.output_text.delta") + require.Equal(t, "", observer.ServiceTier()) + }) +} + +func TestUpstreamResponseModelObserverServiceTierAnthropicSpeed(t *testing.T) { + t.Run("message_start reports standard", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":1,"speed":"standard"}}}`)) + observer.ObserveAnthropic([]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":12}}`)) + require.Equal(t, "standard", observer.ServiceTier()) + require.Equal(t, "claude-opus-5", observer.Model()) + }) + + t.Run("non-stream body reports fast", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"id":"msg_1","type":"message","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":12,"speed":"fast"}}`)) + require.Equal(t, "fast", observer.ServiceTier()) + }) + + t.Run("missing speed declares nothing", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"id":"msg_1","type":"message","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":12}}`)) + require.Equal(t, "", observer.ServiceTier()) + }) +} + +func TestObservedUpstreamResponseServiceTierFromContext(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + require.Equal(t, "", observedUpstreamResponseServiceTier(c)) + + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default"}}`), "response.completed") + require.Equal(t, "default", observedUpstreamResponseServiceTier(c)) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 7a41555ece..b6dc2a9d0e 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -128,7 +128,8 @@ type UsageLog struct { BillingTier *string // BillingMode 计费模式:token/image BillingMode *string - // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". + // ServiceTier records the billable request tier, e.g. OpenAI "priority" / "flex" + // or Anthropic "fast". ServiceTier *string // ReasoningEffort is the request's reasoning effort level. // OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max". diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index b431b50aef..deb102e736 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -10,6 +10,13 @@ func optionalTrimmedStringPtr(raw string) *string { return &trimmed } +func optionalStringValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + func forwardResultBillingModel(requestedModel, upstreamModel string) string { if trimmed := strings.TrimSpace(requestedModel); trimmed != "" { return trimmed diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2e26c0035c..6e708de1d9 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -189,6 +189,29 @@ func ProvideOpenAIQuotaService( return service } +// ProvideOpenAIQuotaAutoResetService 启动账号级自动用卡队列与补偿扫描。 +func ProvideOpenAIQuotaAutoResetService( + accountRepo AccountRepository, + quotaService *OpenAIQuotaService, + rateLimitService *RateLimitService, + idempotency *IdempotencyCoordinator, + audit *AuditLogService, + settingService *SettingService, + leaderLock LeaderLockCache, +) *OpenAIQuotaAutoResetService { + service := NewOpenAIQuotaAutoResetService( + accountRepo, + quotaService, + rateLimitService, + idempotency, + audit, + settingService, + leaderLock, + ) + service.Start() + return service +} + func ProvideAccountUsageService( accountRepo AccountRepository, usageLogRepo UsageLogRepository, @@ -231,6 +254,7 @@ func ProvideAccountTestService( tlsFPProfileService *TLSFingerprintProfileService, openAIGatewayService *OpenAIGatewayService, settingService *SettingService, + pluginManager *PluginManager, ) *AccountTestService { service := NewAccountTestService( accountRepo, @@ -244,6 +268,7 @@ func ProvideAccountTestService( ) service.agentIdentityWS = openAIGatewayService service.SetSettingService(settingService) + service.SetPluginManager(pluginManager) return service } @@ -261,6 +286,45 @@ func ProvideGrokQuotaService( return service } +// ProvideCNProviderQuotaService 构造国产供应商 Coding Plan 额度探测服务。 +func ProvideCNProviderQuotaService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderQuotaService { + return NewCNProviderQuotaService(accountRepo, proxyRepo, httpUpstream, cfg) +} + +// ProvideCNProviderBalanceService 构造国产供应商余额探测服务。 +func ProvideCNProviderBalanceService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderBalanceService { + return NewCNProviderBalanceService(accountRepo, proxyRepo, httpUpstream, cfg) +} + +// ProvideCNProviderBalanceCheckService 构造并启动周期余额/额度检测任务。 +// payg 账号探余额(低余额停调);coding plan 账号探 5h/weekly 滚动窗口 +// (落 extra 快照供调度阈值评估自动停调)。 +// 间隔取自 gateway.cn_providers.balance_check_interval_minutes;<=0 或关闭时不启动。 +func ProvideCNProviderBalanceCheckService( + accountRepo AccountRepository, + balanceService *CNProviderBalanceService, + quotaService *CNProviderQuotaService, + cfg *config.Config, +) *CNProviderBalanceCheckService { + minutes := 10 + if cfg != nil && cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes > 0 { + minutes = cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes + } + svc := NewCNProviderBalanceCheckService(accountRepo, balanceService, quotaService, cfg, time.Duration(minutes)*time.Minute) + svc.Start() + return svc +} + // ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection func ProvideGeminiTokenProvider( accountRepo AccountRepository, @@ -423,6 +487,9 @@ func ProvideRateLimitService( tokenCacheInvalidator TokenCacheInvalidator, ) *RateLimitService { svc := NewRateLimitService(accountRepo, usageRepo, cfg, geminiQuotaService, tempUnschedCache) + if healthCache, ok := tempUnschedCache.(OpenAIAPIKeyHealthCache); ok { + svc.SetOpenAIAPIKeyHealthCache(healthCache) + } svc.SetTimeoutCounterCache(timeoutCounterCache) svc.SetOpenAI403CounterCache(openAI403CounterCache) svc.SetSettingService(settingService) @@ -706,6 +773,9 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit if err := svc.MigrateCodexBodyFingerprintToSignals(context.Background()); err != nil { logger.LegacyPrintf("service.setting", "Warning: migrate codex body fingerprint to signals failed: %v", err) } + if err := svc.MigrateGrokDefaultTextModel(context.Background()); err != nil { + logger.LegacyPrintf("service.setting", "Warning: migrate Grok default text model failed: %v", err) + } antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion) // enforceCodexIdentityHeaders 是所有 Codex 出站路径共用的纯函数收口点,拿不到 ctx, // 故注入无参解析器;解析器内部自带 60s TTL 缓存,热路径不触库。 @@ -795,7 +865,11 @@ var ProviderSet = wire.NewSet( ProvideGrokTokenProvider, ProvideOpenAITokenProvider, ProvideOpenAIQuotaService, + ProvideOpenAIQuotaAutoResetService, ProvideGrokQuotaService, + ProvideCNProviderQuotaService, + ProvideCNProviderBalanceService, + ProvideCNProviderBalanceCheckService, ProvideClaudeTokenProvider, NewAntigravityGatewayService, ProvideRateLimitService, @@ -847,6 +921,7 @@ var ProviderSet = wire.NewSet( NewTotpService, NewErrorPassthroughService, NewTLSFingerprintProfileService, + NewPluginManager, NewDigestSessionStore, ProvideIdempotencyCoordinator, ProvideSystemOperationLockService, @@ -857,6 +932,7 @@ var ProviderSet = wire.NewSet( NewChannelService, wire.Bind(new(ChannelCacheInvalidator), new(*ChannelService)), NewModelPricingResolver, + NewModelPlazaService, NewContentModerationService, NewAffiliateService, ProvidePaymentConfigService, @@ -865,6 +941,7 @@ var ProviderSet = wire.NewSet( ProvideBalanceNotifyService, ProvideChannelMonitorService, ProvideChannelMonitorRunner, + NewChannelMonitorQuotaFetcher, ProvideChannelMonitorV2Service, ProvideChannelMonitorV2Aggregator, NewChannelMonitorRequestTemplateService, @@ -923,13 +1000,20 @@ func ProvideChannelMonitorService( // 通过 SetScheduler 注入回 service 后再 Start,确保启动时加载所有 enabled monitor, // 后续 CRUD 也能即时同步任务表。Runner.Stop 由 cleanup function 调用。 // settingService 用于 runner 每次 fire 读取功能开关。 -func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { +// quotaFetcher(账号侧用量聚合)也在此注入:accountUsage/CN 服务在 wire 图中 +// 晚于 channelMonitorService 构造,走 setter 注入避免调整既有构造顺序。 +func ProvideChannelMonitorRunner( + svc *ChannelMonitorService, + settingService *SettingService, + quotaFetcher *ChannelMonitorQuotaFetcher, +) *ChannelMonitorRunner { r := NewChannelMonitorRunner(svc, settingService) if svc != nil { // Ensure runtime reader is set even if ProvideChannelMonitorService // was constructed without settings (tests / alternate providers). svc.SetRuntimeReader(settingService) svc.SetScheduler(r) + svc.SetQuotaFetcher(quotaFetcher) } r.Start() return r diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 9890ec14a5..339d57d867 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -118,6 +118,13 @@ func (c StubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) er return nil } +func (c StubGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c StubGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", service.ErrReasoningContentNotFound +} + // ============================================================ // StubSessionLimitCache — service.SessionLimitCache 的空实现 // ============================================================ diff --git a/backend/internal/util/responseheaders/responseheaders.go b/backend/internal/util/responseheaders/responseheaders.go index 7f7baca658..5587f43240 100644 --- a/backend/internal/util/responseheaders/responseheaders.go +++ b/backend/internal/util/responseheaders/responseheaders.go @@ -32,6 +32,9 @@ var defaultAllowed = map[string]struct{}{ "retry-after": {}, "location": {}, "www-authenticate": {}, + // Codex uses this response header to avoid estimating reasoning tokens a + // second time when upstream usage already includes them. + "x-reasoning-included": {}, } // hopByHopHeaders 是跳过的 hop-by-hop 头部,这些头部由 HTTP 库自动处理 diff --git a/backend/internal/util/responseheaders/responseheaders_test.go b/backend/internal/util/responseheaders/responseheaders_test.go index d817559e61..4ba1647553 100644 --- a/backend/internal/util/responseheaders/responseheaders_test.go +++ b/backend/internal/util/responseheaders/responseheaders_test.go @@ -38,6 +38,29 @@ func TestFilterHeadersDisabledUsesDefaultAllowlist(t *testing.T) { } } +func TestFilterHeadersAllowsReasoningIncludedByDefault(t *testing.T) { + src := http.Header{} + src.Set("X-Reasoning-Included", "1") + + filtered := FilterHeaders(src, CompileHeaderFilter(config.ResponseHeaderConfig{})) + if got := filtered.Get("X-Reasoning-Included"); got != "1" { + t.Fatalf("expected X-Reasoning-Included passthrough, got %q", got) + } +} + +func TestFilterHeadersForceRemoveOverridesReasoningIncluded(t *testing.T) { + src := http.Header{} + src.Set("X-Reasoning-Included", "1") + + filtered := FilterHeaders(src, CompileHeaderFilter(config.ResponseHeaderConfig{ + Enabled: true, + ForceRemove: []string{"x-reasoning-included"}, + })) + if got := filtered.Get("X-Reasoning-Included"); got != "" { + t.Fatalf("expected X-Reasoning-Included removal, got %q", got) + } +} + func TestFilterHeadersEnabledUsesAllowlist(t *testing.T) { src := http.Header{} src.Add("Content-Type", "application/json") diff --git a/backend/migrations/222_group_usage_daily_rollups.sql b/backend/migrations/222_group_usage_daily_rollups.sql new file mode 100644 index 0000000000..c16b69b4ae --- /dev/null +++ b/backend/migrations/222_group_usage_daily_rollups.sql @@ -0,0 +1,138 @@ +-- /admin/groups 分组用量日汇总。 +-- 迁移创建结构与源表失效触发器,历史数据由后台聚合作业按持久水位回填。 + +CREATE TABLE IF NOT EXISTS usage_group_daily_rollups ( + bucket_date DATE NOT NULL, + group_id BIGINT NOT NULL, + actual_cost DECIMAL(20, 10) NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_date, group_id) +); + +COMMENT ON TABLE usage_group_daily_rollups IS '按北京时间自然日聚合的分组实际费用。'; +COMMENT ON COLUMN usage_group_daily_rollups.bucket_date IS '北京时间自然日。'; + +CREATE TABLE IF NOT EXISTS usage_group_rollup_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + closed_before DATE NOT NULL DEFAULT DATE '1970-01-01', + retained_from TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ '1970-01-01 00:00:00+00', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE usage_group_rollup_state IS '分组日汇总的单行发布水位。'; +COMMENT ON COLUMN usage_group_rollup_state.closed_before IS '已完整发布日桶的北京时间日期排他上界。'; + +INSERT INTO usage_group_rollup_state (id, closed_before, retained_from) +VALUES (1, DATE '1970-01-01', TIMESTAMPTZ '1970-01-01 00:00:00+00') +ON CONFLICT (id) DO NOTHING; + +-- 已发布范围的源记录发生变化时,必须在同一事务内后退发布水位。 +-- DELETE/UPDATE 使用行级触发器,以覆盖外键级联、分区表和直接分区写入。 +CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + affected_date DATE; + published_before DATE; +BEGIN + IF TG_OP = 'DELETE' THEN + affected_date := (OLD.created_at AT TIME ZONE 'Asia/Shanghai')::date; + ELSE + IF OLD.group_id IS NULL THEN + affected_date := (NEW.created_at AT TIME ZONE 'Asia/Shanghai')::date; + ELSIF NEW.group_id IS NULL THEN + affected_date := (OLD.created_at AT TIME ZONE 'Asia/Shanghai')::date; + ELSE + affected_date := LEAST( + (OLD.created_at AT TIME ZONE 'Asia/Shanghai')::date, + (NEW.created_at AT TIME ZONE 'Asia/Shanghai')::date + ); + END IF; + END IF; + + -- 即使当前已发布水位尚未越过受影响日期,也必须先锁行。 + -- 否则并发关闭作业可能在本事务之后把水位推进,覆盖本次失效。 + SELECT closed_before + INTO published_before + FROM usage_group_rollup_state + WHERE id = 1 + FOR UPDATE; + + IF published_before > affected_date THEN + UPDATE usage_group_rollup_state + SET closed_before = LEAST(closed_before, affected_date), + updated_at = NOW() + WHERE id = 1; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +-- INSERT 是网关高频路径。transition table 让每个批量 INSERT 只锁一次状态行; +-- KEY SHARE 在普通写入之间兼容,但会与关闭作业的 FOR UPDATE 串行化。 +CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state_after_insert() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + affected_date DATE; + published_before DATE; +BEGIN + SELECT MIN((created_at AT TIME ZONE 'Asia/Shanghai')::date) + INTO affected_date + FROM inserted_usage_logs + WHERE group_id IS NOT NULL; + + IF affected_date IS NULL THEN + RETURN NULL; + END IF; + + SELECT closed_before + INTO published_before + FROM usage_group_rollup_state + WHERE id = 1 + FOR KEY SHARE; + + IF published_before > affected_date THEN + UPDATE usage_group_rollup_state + SET closed_before = LEAST(closed_before, affected_date), + updated_at = NOW() + WHERE id = 1; + END IF; + + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS usage_logs_group_rollup_invalidate_insert ON usage_logs; +CREATE TRIGGER usage_logs_group_rollup_invalidate_insert +AFTER INSERT ON usage_logs +REFERENCING NEW TABLE AS inserted_usage_logs +FOR EACH STATEMENT +EXECUTE FUNCTION invalidate_group_usage_rollup_state_after_insert(); + +DROP TRIGGER IF EXISTS usage_logs_group_rollup_invalidate_delete ON usage_logs; +CREATE TRIGGER usage_logs_group_rollup_invalidate_delete +AFTER DELETE ON usage_logs +FOR EACH ROW +WHEN (OLD.group_id IS NOT NULL) +EXECUTE FUNCTION invalidate_group_usage_rollup_state(); + +DROP TRIGGER IF EXISTS usage_logs_group_rollup_invalidate_update ON usage_logs; +CREATE TRIGGER usage_logs_group_rollup_invalidate_update +AFTER UPDATE OF created_at, group_id, actual_cost ON usage_logs +FOR EACH ROW +WHEN ( + ( + OLD.created_at IS DISTINCT FROM NEW.created_at + OR OLD.group_id IS DISTINCT FROM NEW.group_id + OR OLD.actual_cost IS DISTINCT FROM NEW.actual_cost + ) + AND (OLD.group_id IS NOT NULL OR NEW.group_id IS NOT NULL) +) +EXECUTE FUNCTION invalidate_group_usage_rollup_state(); diff --git a/backend/migrations/223_group_usage_rollup_timezone.sql b/backend/migrations/223_group_usage_rollup_timezone.sql new file mode 100644 index 0000000000..8de2bdff97 --- /dev/null +++ b/backend/migrations/223_group_usage_rollup_timezone.sql @@ -0,0 +1,89 @@ +-- 让 /admin/groups 分组日汇总跟随服务端配置时区。 +-- 222 迁移生成的存量日桶均为北京时间,因此新增状态默认标记为 Asia/Shanghai; +-- 服务启动后若当前 TZ 不同,后台同步会检测到不一致并重建日桶。 + +ALTER TABLE usage_group_rollup_state + ADD COLUMN IF NOT EXISTS timezone_name TEXT NOT NULL DEFAULT 'Asia/Shanghai'; + +COMMENT ON COLUMN usage_group_rollup_state.timezone_name IS '当前分组日桶采用的 IANA 时区名称。'; +COMMENT ON COLUMN usage_group_rollup_state.closed_before IS '已完整发布日桶的配置时区日期排他上界。'; +COMMENT ON COLUMN usage_group_daily_rollups.bucket_date IS 'timezone_name 对应时区的自然日。'; + +CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + affected_date DATE; + published_before DATE; + configured_timezone TEXT := current_setting('TimeZone'); +BEGIN + IF TG_OP = 'DELETE' THEN + affected_date := (OLD.created_at AT TIME ZONE configured_timezone)::date; + ELSE + IF OLD.group_id IS NULL THEN + affected_date := (NEW.created_at AT TIME ZONE configured_timezone)::date; + ELSIF NEW.group_id IS NULL THEN + affected_date := (OLD.created_at AT TIME ZONE configured_timezone)::date; + ELSE + affected_date := LEAST( + (OLD.created_at AT TIME ZONE configured_timezone)::date, + (NEW.created_at AT TIME ZONE configured_timezone)::date + ); + END IF; + END IF; + + SELECT closed_before + INTO published_before + FROM usage_group_rollup_state + WHERE id = 1 + FOR UPDATE; + + IF published_before > affected_date THEN + UPDATE usage_group_rollup_state + SET closed_before = LEAST(closed_before, affected_date), + updated_at = NOW() + WHERE id = 1; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state_after_insert() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + affected_date DATE; + published_before DATE; + configured_timezone TEXT := current_setting('TimeZone'); +BEGIN + SELECT MIN((created_at AT TIME ZONE configured_timezone)::date) + INTO affected_date + FROM inserted_usage_logs + WHERE group_id IS NOT NULL; + + IF affected_date IS NULL THEN + RETURN NULL; + END IF; + + SELECT closed_before + INTO published_before + FROM usage_group_rollup_state + WHERE id = 1 + FOR KEY SHARE; + + IF published_before > affected_date THEN + UPDATE usage_group_rollup_state + SET closed_before = LEAST(closed_before, affected_date), + updated_at = NOW() + WHERE id = 1; + END IF; + + RETURN NULL; +END; +$$; diff --git a/backend/migrations/224_user_platform_quotas_add_cn_providers.sql b/backend/migrations/224_user_platform_quotas_add_cn_providers.sql new file mode 100644 index 0000000000..011a7762af --- /dev/null +++ b/backend/migrations/224_user_platform_quotas_add_cn_providers.sql @@ -0,0 +1,17 @@ +-- 把 kimi/zhipu/deepseek 平台加入 user_platform_quotas.platform 的 CHECK 约束。 +-- +-- 背景:国产供应商进入 AllowedQuotaPlatforms(internal/service/domain_constants.go), +-- 注册时 GetDefaultPlatformQuotas 会为全部 8 平台预填充默认配额行,但 157 号迁移的 +-- CHECK 仍只允许 5 平台。BulkInsertInitial 是单条多行 INSERT,任一违约行会中止整条 +-- 语句 → 注册路径 fail-open 吞错 → 新用户拿到零条配额记录(含原有 5 平台,缺失配额 +-- 行 = 无限额)。与 157 头注释记载的 grok 同型事故一致。 +-- +-- 修复:把约束与代码平台列表(PlatformKimi/PlatformZhipu/PlatformDeepseek)对齐。 +-- DROP ... IF EXISTS 保证可重入;新约束是旧约束的超集,存量行(仅 5 平台)瞬时校验通过。 +ALTER TABLE user_platform_quotas + DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check; + +ALTER TABLE user_platform_quotas + ADD CONSTRAINT user_platform_quotas_platform_check + CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek')); diff --git a/backend/migrations/225_backfill_codex_fingerprint_seed.sql b/backend/migrations/225_backfill_codex_fingerprint_seed.sql new file mode 100644 index 0000000000..5c7bdbb2f6 --- /dev/null +++ b/backend/migrations/225_backfill_codex_fingerprint_seed.sql @@ -0,0 +1,21 @@ +-- Backfill system-managed Codex fingerprint seeds for enabled OpenAI OAuth accounts. +-- Idempotent: valid canonical seeds are preserved on rerun. +UPDATE accounts +SET extra = jsonb_set( + COALESCE(extra, '{}'::jsonb), + '{codex_fingerprint_seed}', + to_jsonb(gen_random_uuid()::text), + true +) +WHERE deleted_at IS NULL + AND platform = 'openai' + AND type = 'oauth' + AND COALESCE(extra->>'codex_fingerprint_mode', '') IN ('device', 'session', 'full') + AND ( + extra->>'codex_fingerprint_seed' IS NULL + OR btrim(extra->>'codex_fingerprint_seed') = '' + OR NOT ( + extra->>'codex_fingerprint_seed' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + AND extra->>'codex_fingerprint_seed' <> '00000000-0000-0000-0000-000000000000' + ) + ); diff --git a/backend/migrations/225_channel_model_time_pricing.sql b/backend/migrations/225_channel_model_time_pricing.sql new file mode 100644 index 0000000000..dbfb84cde4 --- /dev/null +++ b/backend/migrations/225_channel_model_time_pricing.sql @@ -0,0 +1,5 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS time_pricing JSONB NULL; + +COMMENT ON COLUMN channel_model_pricing.time_pricing IS + 'Optional IANA timezone and recurring daily multiplier periods for channel token pricing'; diff --git a/backend/migrations/226_add_usage_log_effective_model_indexes_notx.sql b/backend/migrations/226_add_usage_log_effective_model_indexes_notx.sql new file mode 100644 index 0000000000..7bb6250fba --- /dev/null +++ b/backend/migrations/226_add_usage_log_effective_model_indexes_notx.sql @@ -0,0 +1,13 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created + ON usage_logs ( + (COALESCE(NULLIF(BTRIM(requested_model), ''), model)), + created_at DESC, + id DESC + ); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created + ON usage_logs ( + (COALESCE(NULLIF(BTRIM(upstream_model), ''), model)), + created_at DESC, + id DESC + ); diff --git a/backend/migrations/226_channel_monitor_quota_mode.sql b/backend/migrations/226_channel_monitor_quota_mode.sql new file mode 100644 index 0000000000..a0774e3dbf --- /dev/null +++ b/backend/migrations/226_channel_monitor_quota_mode.sql @@ -0,0 +1,78 @@ +-- Migration: 226_channel_monitor_quota_mode +-- 渠道监控配额模式: +-- 1. provider 扩容到全部 8 平台(antigravity/kimi/zhipu/deepseek) +-- (antigravity 仅支持配额模式,无探活 adapter;国产 3 家复用 OpenAI 兼容探活) +-- 2. check_mode:probe(默认,现状探活)/ quota(仅查关联账号用量,零 LLM 成本) +-- / quota_probe(探活 + 配额并存) +-- 3. account_id 关联已有账号(配额模式的数据源,复用账号侧用量服务); +-- 账号删除时置空,监控保留并报「账号未关联」 +-- 4. channel_monitor_histories.quota 持久化归一化配额快照(JSONB) +-- 5. 新增公开设置 channel_monitor_show_quota(默认关闭): +-- 控制用户端监控页是否展示配额/余额;管理端始终可见 + +DO $$ +DECLARE + monitor_constraint_def TEXT; + template_constraint_def TEXT; +BEGIN + SELECT pg_get_constraintdef(c.oid) + INTO monitor_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitors' + AND c.conname = 'channel_monitors_provider_check'; + + IF monitor_constraint_def IS NULL OR position('kimi' IN monitor_constraint_def) = 0 THEN + ALTER TABLE channel_monitors + DROP CONSTRAINT IF EXISTS channel_monitors_provider_check; + ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek')); + END IF; + + SELECT pg_get_constraintdef(c.oid) + INTO template_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitor_request_templates' + AND c.conname = 'channel_monitor_request_templates_provider_check'; + + IF template_constraint_def IS NULL OR position('kimi' IN template_constraint_def) = 0 THEN + ALTER TABLE channel_monitor_request_templates + DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check; + ALTER TABLE channel_monitor_request_templates + ADD CONSTRAINT channel_monitor_request_templates_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek')); + END IF; +END $$; + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS check_mode VARCHAR(32) NOT NULL DEFAULT 'probe'; + +ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_check_mode_check + CHECK (check_mode IN ('probe', 'quota', 'quota_probe')); + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channel_monitors_account_id ON channel_monitors(account_id); + +COMMENT ON COLUMN channel_monitors.check_mode IS + 'probe = LLM 探活(默认);quota = 仅查关联账号用量;quota_probe = 探活 + 配额'; +COMMENT ON COLUMN channel_monitors.account_id IS + '配额模式关联的账号 ID(数据源);账号删除时置空'; + +ALTER TABLE channel_monitor_histories + ADD COLUMN IF NOT EXISTS quota JSONB; + +COMMENT ON COLUMN channel_monitor_histories.quota IS + '配额模式监控的归一化配额快照(domain.MonitorQuotaSnapshot);探活模式为 NULL'; + +-- 用户端是否展示配额/余额(默认关闭,fail-closed 解析:仅 "true" 视为开启)。 +-- 管理端不受此开关影响。 +INSERT INTO settings (key, value) +VALUES ('channel_monitor_show_quota', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/227_composite_routes_add_cn_providers.sql b/backend/migrations/227_composite_routes_add_cn_providers.sql new file mode 100644 index 0000000000..ec4b228241 --- /dev/null +++ b/backend/migrations/227_composite_routes_add_cn_providers.sql @@ -0,0 +1,8 @@ +-- Allow Composite model routes to target the three concrete CN providers. +ALTER TABLE composite_model_routes + DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check; + +ALTER TABLE composite_model_routes + ADD CONSTRAINT composite_model_routes_target_platform_check + CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek')); diff --git a/backend/migrations/228_channel_pricing_multipliers.sql b/backend/migrations/228_channel_pricing_multipliers.sql new file mode 100644 index 0000000000..33322ba1a3 --- /dev/null +++ b/backend/migrations/228_channel_pricing_multipliers.sql @@ -0,0 +1,56 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS fast_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS flex_multiplier NUMERIC(12,6); + +ALTER TABLE channel_pricing_intervals + ADD COLUMN IF NOT EXISTS input_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS output_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS cache_write_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS cache_read_multiplier NUMERIC(12,6); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_model_pricing_fast_multiplier_positive' AND conrelid = 'channel_model_pricing'::regclass) THEN + ALTER TABLE channel_model_pricing + ADD CONSTRAINT channel_model_pricing_fast_multiplier_positive + CHECK (fast_multiplier IS NULL OR fast_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_model_pricing_flex_multiplier_positive' AND conrelid = 'channel_model_pricing'::regclass) THEN + ALTER TABLE channel_model_pricing + ADD CONSTRAINT channel_model_pricing_flex_multiplier_positive + CHECK (flex_multiplier IS NULL OR flex_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_input_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_input_multiplier_positive + CHECK (input_multiplier IS NULL OR input_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_output_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_output_multiplier_positive + CHECK (output_multiplier IS NULL OR output_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_cache_write_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_cache_write_multiplier_positive + CHECK (cache_write_multiplier IS NULL OR cache_write_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_cache_read_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_cache_read_multiplier_positive + CHECK (cache_read_multiplier IS NULL OR cache_read_multiplier > 0); + END IF; +END $$; + +COMMENT ON COLUMN channel_model_pricing.fast_multiplier IS + 'Fast/priority service tier multiplier applied to the selected standard channel price'; +COMMENT ON COLUMN channel_model_pricing.flex_multiplier IS + 'Flex service tier multiplier applied to the selected standard channel price'; +COMMENT ON COLUMN channel_pricing_intervals.input_multiplier IS + 'Interval input multiplier applied to the channel base input price when input_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.output_multiplier IS + 'Interval output multiplier applied to the channel base output price when output_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.cache_write_multiplier IS + 'Interval cache-write multiplier applied to the channel base cache-write price when cache_write_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.cache_read_multiplier IS + 'Interval cache-read multiplier applied to the channel base cache-read price when cache_read_price is NULL'; diff --git a/backend/migrations/229_plugins.sql b/backend/migrations/229_plugins.sql new file mode 100644 index 0000000000..247fb7a936 --- /dev/null +++ b/backend/migrations/229_plugins.sql @@ -0,0 +1,54 @@ +-- 管理员手动上传的本地进程插件。 +-- 插件默认停用;账号表不保存任何插件字段。 +-- 表名使用 sub2api 前缀,避免与部署数据库中已有的通用插件表冲突。 + +CREATE TABLE IF NOT EXISTS sub2api_plugin_installations ( + id BIGSERIAL PRIMARY KEY, + plugin_key VARCHAR(160) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + version VARCHAR(64) NOT NULL, + description TEXT NOT NULL DEFAULT '', + author VARCHAR(160) NOT NULL DEFAULT '', + manifest JSONB NOT NULL DEFAULT '{}'::jsonb, + artifact_path TEXT NOT NULL, + install_path TEXT NOT NULL, + binary_path TEXT NOT NULL, + binary_sha256 VARCHAR(64) NOT NULL, + signature_status VARCHAR(32) NOT NULL DEFAULT 'unsigned', + state VARCHAR(32) NOT NULL DEFAULT 'disabled', + config_encrypted TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + installed_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + enabled_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT sub2api_plugin_installations_state_check + CHECK (state IN ('disabled', 'starting', 'enabled', 'error', 'incompatible')), + CONSTRAINT sub2api_plugin_installations_signature_status_check + CHECK (signature_status IN ('trusted', 'unsigned')) +); + +CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings ( + id BIGSERIAL PRIMARY KEY, + plugin_id BIGINT NOT NULL REFERENCES sub2api_plugin_installations(id) ON DELETE CASCADE, + capability VARCHAR(160) NOT NULL, + platform VARCHAR(32) NOT NULL, + account_type VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + rollout_percent INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT sub2api_plugin_bindings_rollout_check CHECK (rollout_percent BETWEEN 0 AND 100), + CONSTRAINT sub2api_plugin_bindings_scope_unique UNIQUE (plugin_id, capability, platform, account_type) +); + +CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_plugin_id + ON sub2api_plugin_bindings(plugin_id); + +CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_enabled_scope + ON sub2api_plugin_bindings(platform, account_type, capability) + WHERE enabled = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope + ON sub2api_plugin_bindings(capability, platform, account_type) + WHERE enabled = TRUE; diff --git a/backend/migrations/230_plugin_artifacts.sql b/backend/migrations/230_plugin_artifacts.sql new file mode 100644 index 0000000000..c296502852 --- /dev/null +++ b/backend/migrations/230_plugin_artifacts.sql @@ -0,0 +1,4 @@ +-- 保存经过签名校验的原始插件包,供多实例和无状态节点重新复验、解包。 +-- 旧安装记录允许暂时为空;管理员重新上传该插件后会自动补齐。 +ALTER TABLE sub2api_plugin_installations + ADD COLUMN IF NOT EXISTS artifact_data BYTEA; diff --git a/backend/migrations/channel_monitor_quota_mode_migration_test.go b/backend/migrations/channel_monitor_quota_mode_migration_test.go new file mode 100644 index 0000000000..69d2dacaef --- /dev/null +++ b/backend/migrations/channel_monitor_quota_mode_migration_test.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorQuotaModeMigration(t *testing.T) { + content, err := FS.ReadFile("226_channel_monitor_quota_mode.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + + // provider CHECK 两张表扩到 8 平台,且带幂等守卫(仿 176 grok 迁移)。 + require.Contains(t, sql, "channel_monitors_provider_check") + require.Contains(t, sql, "channel_monitor_request_templates_provider_check") + require.Contains(t, sql, "CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', 'antigravity', 'kimi', 'zhipu', 'deepseek'))") + require.Contains(t, sql, "position('kimi' IN monitor_constraint_def) = 0") + require.Contains(t, sql, "position('kimi' IN template_constraint_def) = 0") + + // check_mode 三态,默认 probe。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS check_mode VARCHAR(32) NOT NULL DEFAULT 'probe'") + require.Contains(t, sql, "CHECK (check_mode IN ('probe', 'quota', 'quota_probe'))") + + // account_id 关联账号,账号删除置空(监控保留,运行时报「账号未关联」)。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS idx_channel_monitors_account_id ON channel_monitors(account_id)") + + // 历史表配额快照列。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS quota JSONB") + + // 公开设置默认关闭。 + require.Contains(t, sql, "VALUES ('channel_monitor_show_quota', 'false')") + require.Contains(t, sql, "ON CONFLICT (key) DO NOTHING") +} diff --git a/backend/migrations/channel_pricing_multipliers_migration_test.go b/backend/migrations/channel_pricing_multipliers_migration_test.go new file mode 100644 index 0000000000..96ff90f53b --- /dev/null +++ b/backend/migrations/channel_pricing_multipliers_migration_test.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelPricingMultipliersMigration(t *testing.T) { + content, err := FS.ReadFile("228_channel_pricing_multipliers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + for _, column := range []string{ + "fast_multiplier NUMERIC(12,6)", + "flex_multiplier NUMERIC(12,6)", + "input_multiplier NUMERIC(12,6)", + "output_multiplier NUMERIC(12,6)", + "cache_write_multiplier NUMERIC(12,6)", + "cache_read_multiplier NUMERIC(12,6)", + } { + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS "+column) + } + + constraints := []struct { + table string + name string + column string + }{ + {"channel_model_pricing", "channel_model_pricing_fast_multiplier_positive", "fast_multiplier"}, + {"channel_model_pricing", "channel_model_pricing_flex_multiplier_positive", "flex_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_input_multiplier_positive", "input_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_output_multiplier_positive", "output_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_cache_write_multiplier_positive", "cache_write_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_cache_read_multiplier_positive", "cache_read_multiplier"}, + } + for _, constraint := range constraints { + require.Contains(t, sql, "conname = '"+constraint.name+"' AND conrelid = '"+constraint.table+"'::regclass") + require.Contains(t, sql, "ALTER TABLE "+constraint.table+" ADD CONSTRAINT "+constraint.name+ + " CHECK ("+constraint.column+" IS NULL OR "+constraint.column+" > 0)") + } +} diff --git a/backend/migrations/composite_routes_cn_providers_migration_test.go b/backend/migrations/composite_routes_cn_providers_migration_test.go new file mode 100644 index 0000000000..7b6bba7471 --- /dev/null +++ b/backend/migrations/composite_routes_cn_providers_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompositeRoutesCNProvidersMigration(t *testing.T) { + content, err := FS.ReadFile("227_composite_routes_add_cn_providers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check") + require.Contains(t, sql, + "CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'))") +} diff --git a/backend/migrations/group_usage_rollup_migration_test.go b/backend/migrations/group_usage_rollup_migration_test.go new file mode 100644 index 0000000000..f2e9a5d0ad --- /dev/null +++ b/backend/migrations/group_usage_rollup_migration_test.go @@ -0,0 +1,52 @@ +//go:build unit + +package migrations + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMigration222CreatesGroupUsageRollups(t *testing.T) { + content, err := FS.ReadFile("222_group_usage_daily_rollups.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS usage_group_daily_rollups") + require.Contains(t, sql, "actual_cost DECIMAL(20, 10)") + require.Contains(t, sql, "PRIMARY KEY (bucket_date, group_id)") + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS usage_group_rollup_state") + require.Contains(t, sql, "CHECK (id = 1)") + require.Contains(t, sql, "TIMESTAMPTZ '1970-01-01 00:00:00+00'") + require.Contains(t, sql, "ON CONFLICT (id) DO NOTHING") +} + +func TestMigration222InvalidatesClosedBucketsWhenUsageLogsChange(t *testing.T) { + content, err := FS.ReadFile("222_group_usage_daily_rollups.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state") + require.Contains(t, sql, "SELECT closed_before") + require.Contains(t, sql, "FOR UPDATE") + require.Contains(t, sql, "FOR KEY SHARE") + require.Contains(t, sql, "REFERENCING NEW TABLE AS inserted_usage_logs") + require.Contains(t, sql, "closed_before = LEAST(closed_before, affected_date)") + require.Contains(t, sql, "CREATE TRIGGER usage_logs_group_rollup_invalidate_insert") + require.Contains(t, sql, "CREATE TRIGGER usage_logs_group_rollup_invalidate_delete") + require.Contains(t, sql, "CREATE TRIGGER usage_logs_group_rollup_invalidate_update") + require.Contains(t, sql, "AFTER UPDATE OF created_at, group_id, actual_cost") +} + +func TestMigration223TracksConfiguredTimezone(t *testing.T) { + content, err := FS.ReadFile("223_group_usage_rollup_timezone.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS timezone_name TEXT") + require.Contains(t, sql, "DEFAULT 'Asia/Shanghai'") + require.Contains(t, sql, "current_setting('TimeZone')") + require.Contains(t, sql, "CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state") + require.Contains(t, sql, "CREATE OR REPLACE FUNCTION invalidate_group_usage_rollup_state_after_insert") +} diff --git a/backend/migrations/plugins_migration_test.go b/backend/migrations/plugins_migration_test.go new file mode 100644 index 0000000000..10a1493bc2 --- /dev/null +++ b/backend/migrations/plugins_migration_test.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPluginsMigrationKeepsAccountSchemaUnchanged(t *testing.T) { + content, err := FS.ReadFile("229_plugins.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_installations") + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings") + require.Contains(t, sql, "config_encrypted TEXT NOT NULL DEFAULT ''") + require.Contains(t, sql, "REFERENCES sub2api_plugin_installations(id)") + require.Contains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope") + require.Contains(t, sql, "WHERE enabled = TRUE") + require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_installations") + require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_bindings") + require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS") + require.NotContains(t, sql, "account_id") +} + +func TestPluginArtifactMigrationSupportsExistingInstallations(t *testing.T) { + content, err := FS.ReadFile("230_plugin_artifacts.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "ALTER TABLE sub2api_plugin_installations") + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS artifact_data BYTEA") + require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS") +} diff --git a/backend/migrations/usage_log_effective_model_index_test.go b/backend/migrations/usage_log_effective_model_index_test.go new file mode 100644 index 0000000000..9505889dc8 --- /dev/null +++ b/backend/migrations/usage_log_effective_model_index_test.go @@ -0,0 +1,19 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUsageLogEffectiveModelIndexesMigration(t *testing.T) { + content, err := FS.ReadFile("226_add_usage_log_effective_model_indexes_notx.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created") + require.Contains(t, sql, "(COALESCE(NULLIF(BTRIM(requested_model), ''), model)), created_at DESC, id DESC") + require.Contains(t, sql, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created") + require.Contains(t, sql, "(COALESCE(NULLIF(BTRIM(upstream_model), ''), model)), created_at DESC, id DESC") +} diff --git a/backend/migrations/user_platform_quota_cn_providers_migration_test.go b/backend/migrations/user_platform_quota_cn_providers_migration_test.go new file mode 100644 index 0000000000..73bfd05767 --- /dev/null +++ b/backend/migrations/user_platform_quota_cn_providers_migration_test.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestUserPlatformQuotasCNProvidersMigration 校验 224 号迁移把 kimi/zhipu/deepseek +// 加入 user_platform_quotas.platform 的 CHECK 约束(对照 157 号 grok 迁移)。 +// 约束未放宽时,注册预填充 8 平台默认配额会整条 INSERT 中止 → 新用户零配额行 +// (缺失配额行 = 无限额),管理端设置国产平台配额直接 500。 +func TestUserPlatformQuotasCNProvidersMigration(t *testing.T) { + content, err := FS.ReadFile("224_user_platform_quotas_add_cn_providers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check") + require.Contains(t, sql, + "CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'))") +} diff --git a/backend/pkg/pluginapi/README.md b/backend/pkg/pluginapi/README.md new file mode 100644 index 0000000000..b8f8b0efff --- /dev/null +++ b/backend/pkg/pluginapi/README.md @@ -0,0 +1,80 @@ +# Sub2API 本地插件协议 + +本目录是插件开发者可以依赖的公开契约。`v1/plugin.proto` 和 `v1/runtime.go` 定义进程协议,`v1/manifest.schema.json` 定义包清单,`docs/` 记录开发和发布规范。Provider 私有实现不应放入本目录。 + +## 开发文档 + +- [开发指南](docs/development.md):从运行时、配置到集成测试的完整流程。 +- [UI Bridge](docs/ui-bridge.md):沙箱配置 UI 的消息结构和安全要求。 +- [包格式](docs/package-format.md):清单、文件哈希、签名和版本规则。 +- [安全边界](docs/security.md):进程权限、敏感数据和故障策略。 + +## 实体与运行方式 + +插件的交付实体是一个 `.s2plugin` 文件,本质上是带清单、签名、独立可执行文件和静态 UI 的 ZIP 包。管理员在独立的插件管理页手动上传,Sub2API 不从网络自动下载插件,也不要求 Docker。 + +启用后,Sub2API 以子进程方式拉起当前操作系统和 CPU 架构对应的二进制,通过本机 gRPC 流传递请求与响应。插件进程退出时会随 Sub2API 清理;停用时先停止接收新请求,再等待正在处理的请求结束。 + +多实例部署不要求共享插件目录。宿主会在数据库保存已验签的原始插件包,各实例缺少本地文件时会重新验签和解包,并周期性对齐启用状态、灰度比例和加密配置。所有实例必须连接同一数据库并使用相同的加密密钥。 + +独立进程是代码和发布边界,不是操作系统安全沙箱。插件拥有 Sub2API 服务用户所拥有的文件和网络权限,因此只应安装可信发布者的签名包。闭源二进制可提高源码分发门槛,但不能承诺无法反编译。 + +## 初期能力边界 + +当前只接受 `openai.oauth.outbound_transport.v1`: + +- 仅匹配 `platform=openai` 且 `account_type=oauth` 的上游 HTTP 请求。 +- API Key 账号、其他 provider、OAuth 登录与 Token 刷新流程不进入插件。 +- 插件建立真实的上游 HTTP/TLS 连接并返回原始 HTTP 响应。 +- 命中插件的 OAuth WebSocket 账号会使用 Sub2API 现有 HTTP Bridge,不直接建立上游 WebSocket,避免绕过 v1 HTTP 插件协议。 +- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。 +- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。 + +## 包结构 + +```text +manifest.json +signature.json # 生产包必需 +runtimes/linux-amd64/plugin +runtimes/linux-arm64/plugin +runtimes/windows-amd64/plugin.exe +ui/index.html +ui/assets/... +``` + +`manifest.json` 必须声明所有运行时和 UI 文件的 SHA-256。`signature.json` 使用受信任发布者的 Ed25519 私钥对 `manifest.json` 原始字节签名。官方 OpenAI Transport 公钥由宿主内置,第三方发布者公钥由部署者追加到 `plugins.trusted_publishers`。文件哈希由已签名清单保护。 + +插件默认保持停用。未签名包默认拒绝安装;`plugins.allow_unsigned` 只应用于开发者自己构建的本地调试包。 + +## 兼容性 + +清单必须同时声明: + +- `requires.sub2api`:允许的 Sub2API 语义化版本范围。 +- `requires.recommended_sub2api_version`:建议使用的宿主版本。 +- `requires.tested_sub2api_versions`:发布者实际验证过的宿主版本。 +- `plugin_protocol`、`transport_api`、`ui_bridge`:三个独立协议版本。 + +宿主版本超出范围时,插件可以安装并查看,但保持“不兼容”状态且不能启用。版本在范围内但未列入已测试版本时,管理员必须再次确认才能启用。 + +## UI 隔离与 Bridge + +插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。 + +UI 可以发送以下消息: + +- `config.load` +- `config.save` +- `config.test` +- `ui.resize` +- `ui.notify` + +每个请求消息带 `request_id`,宿主以 `.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。 + +## 协议源码 + +- `v1/plugin.proto`:稳定的进程间消息定义。 +- `v1/runtime.go`:Go 插件进程启动入口和宿主客户端声明。 +- `v1/manifest.schema.json`:`manifest.json` 的 JSON Schema。 + +插件通过进程协议协作,不使用 Go 动态链接,也不要求插件与 Sub2API 使用相同编译器或共享内存 ABI。 diff --git a/backend/pkg/pluginapi/docs/development.md b/backend/pkg/pluginapi/docs/development.md new file mode 100644 index 0000000000..65d9d0f415 --- /dev/null +++ b/backend/pkg/pluginapi/docs/development.md @@ -0,0 +1,67 @@ +# 插件开发指南 + +## 稳定边界 + +当前宿主只支持 `openai.oauth.outbound_transport.v1`。插件负责建立实际上游 HTTP/TLS 连接,Sub2API 负责账号选择、OAuth Token 生命周期、下游协议、响应解析、SSE、错误映射、用量统计和计费。 + +插件不应修改 API Key 路径,也不应自行刷新或持久化 OAuth Token。 + +## 推荐结构 + +```text +plugin/ +├── cmd//main.go +├── internal/config/ +├── internal/transport/ +├── ui/index.html +├── ui/assets/ +├── tools/packager/ +├── manifest.source.json +└── README.md +``` + +入口只调用 `pluginv1.Serve`。配置解析和传输实现放入独立包,以便不启动子进程就能单元测试。 + +## 运行时方法 + +| 方法 | 要求 | +|---|---| +| `GetInfo` | ID、版本、协议和能力必须与清单一致 | +| `Health` | 返回进程是否可以接受新请求,不执行昂贵探测 | +| `ValidateConfig` | 严格解析并返回完整规范化 JSON | +| `ApplyConfig` | 原子应用配置;失败时保留旧配置 | +| `TestConfig` | 验证当前环境和已保存配置,返回简短诊断 | +| `Forward` | 双向流式传输请求与原始 HTTP 响应 | + +请求帧顺序:`start`、零到多个 `body_chunk`、`body_end`。响应帧顺序:`start`、零到多个 `body_chunk`、`end`。不能继续处理的错误使用 `error` 帧。 + +`request_sent` 必须如实表示请求是否可能已经到达上游。值为 `true` 时宿主禁止自动切换账号重放;只有能确认尚未调用上游 Transport 时才能返回 `false`。 + +## 配置 + +- JSON 字段统一使用 `snake_case`。 +- 拒绝未知字段、非法范围和受保护请求头。 +- 默认配置必须完整,空对象应规范化为所有默认字段。 +- 保存时由插件先验证和应用,再由宿主加密写入数据库。 +- 数据库写入失败时宿主会尝试恢复旧配置,插件必须允许重复应用。 + +## 资源管理 + +- 复用 HTTP Transport 和连接池,不要为每个请求创建新连接池。 +- 配置切换后关闭旧空闲连接。 +- 使用 stream context 取消 DNS、连接、上传和响应读取。 +- 始终关闭上游响应体。 +- 不在插件内无限缓存按账号区分的客户端。 + +## 最低测试集 + +- 配置默认值、未知字段、边界值和深复制。 +- 插件身份及协议版本。 +- 请求体分块、无请求体、固定 Content-Length。 +- 响应状态、重复请求头、流式响应和响应读取错误。 +- 上下文取消、插件退出和超时。 +- 代理开启与禁用。 +- 包哈希、签名、路径穿越和目标平台运行时。 +- UI Bridge 加载、保存、测试、错误和超时。 + +发布前还应使用真实构建包运行宿主的插件进程集成测试。 diff --git a/backend/pkg/pluginapi/docs/package-format.md b/backend/pkg/pluginapi/docs/package-format.md new file mode 100644 index 0000000000..750760808f --- /dev/null +++ b/backend/pkg/pluginapi/docs/package-format.md @@ -0,0 +1,45 @@ +# `.s2plugin` 包格式 + +`.s2plugin` 是 ZIP 文件,根目录必须包含 `manifest.json`,生产包还必须包含 `signature.json`。 + +## 标准布局 + +```text +manifest.json +signature.json +runtimes/-/ +ui/index.html +ui/assets/... +``` + +所有运行时和 UI 文件必须出现在 `manifest.files`,值为小写十六进制 SHA-256。清单和签名文件自身不写入 `files`。 + +包不允许绝对路径、父目录跳转、重复路径、符号链接、未声明文件或缺失文件。宿主还限制上传大小、解压后大小和文件数量。 + +## 清单 + +字段规范见 [`v1/manifest.schema.json`](../v1/manifest.schema.json)。版本字段含义: + +- `version`:插件自身语义化版本。 +- `requires.sub2api`:宿主硬兼容范围。 +- `recommended_sub2api_version`:建议宿主版本。 +- `tested_sub2api_versions`:发布者真实验证过的版本。 +- `plugin_protocol`:进程握手协议。 +- `transport_api`:请求和响应帧协议。 +- `ui_bridge`:配置 UI 消息协议。 + +## 签名 + +`signature.json`: + +```json +{ + "algorithm": "ed25519", + "key_id": "publisher-key-id", + "signature": "BASE64_SIGNATURE" +} +``` + +签名对象是 `manifest.json` 的精确原始字节。发布者私钥不得进入插件包、源码仓库或 Sub2API 运行环境。部署者只配置 Base64 Ed25519 公钥。 + +默认生产配置拒绝未签名包。官方 OpenAI Transport 使用宿主内置公钥验签,不需要配置;其他发布者仍需配置 `trusted_publishers`。`allow_unsigned` 只用于开发者自己构建的本地包。 diff --git a/backend/pkg/pluginapi/docs/security.md b/backend/pkg/pluginapi/docs/security.md new file mode 100644 index 0000000000..1cad27ac69 --- /dev/null +++ b/backend/pkg/pluginapi/docs/security.md @@ -0,0 +1,29 @@ +# 插件安全边界 + +## 能提供的隔离 + +- 私有实现以独立二进制交付,宿主公开源码不包含其业务逻辑。 +- 进程协议避免 Go 动态链接和共享内存 ABI。 +- 包签名和文件哈希防止未授权替换。 +- UI 使用短时 URL、独立 Bridge Token 和 sandbox iframe。 +- 插件故障时 OAuth 插件路径失败关闭,不静默切回另一种网络行为。 + +## 不能提供的保证 + +- 闭源二进制仍可能被逆向分析。 +- 子进程不是操作系统沙箱。 +- 插件拥有 Sub2API 服务用户可访问的文件、环境变量和网络权限。 +- 包签名证明发布者身份,不证明实现无漏洞或符合 Provider 条款。 + +## 部署要求 + +- 官方 OpenAI Transport 使用宿主内置公钥;只向 `plugins.trusted_publishers` 添加经过审核的第三方公钥。 +- 使用专用低权限系统用户运行 Sub2API。 +- 限制该用户的文件权限、出站网络和环境变量。 +- 不向插件环境注入无关密钥。 +- 对插件升级保留旧包和回滚流程。 +- 记录安装、启用、停用、配置和删除操作,但不记录配置明文。 + +## 敏感数据 + +插件处理真实 OAuth Authorization 请求头,必须避免将请求头、请求体、代理凭据和上游敏感响应写入日志或诊断消息。UI 配置中不应出现 OAuth Token。 diff --git a/backend/pkg/pluginapi/docs/ui-bridge.md b/backend/pkg/pluginapi/docs/ui-bridge.md new file mode 100644 index 0000000000..9f39fd0c66 --- /dev/null +++ b/backend/pkg/pluginapi/docs/ui-bridge.md @@ -0,0 +1,56 @@ +# UI Bridge v1 + +## 加载方式 + +宿主为每次打开配置页创建短时 UI 会话: + +```text +/api/v1/plugin-ui//index.html#bridge_token= +``` + +资源 Token 用于读取包内 `ui/` 文件,Bridge Token 只存在于 URL fragment,不会发送到服务器。iframe 使用 `sandbox="allow-scripts"`,不授予 `allow-same-origin`。 + +UI 只能加载包内、已在清单声明的资源。CSP 禁止外部网络连接、表单提交和外部 frame。 + +## 消息信封 + +UI 到宿主: + +```json +{ + "source": "sub2api-plugin-ui", + "bridge_token": "TOKEN", + "type": "config.load", + "request_id": "UNIQUE_ID" +} +``` + +宿主到 UI: + +```json +{ + "source": "sub2api-plugin-host", + "bridge_token": "TOKEN", + "request_id": "UNIQUE_ID", + "ok": true +} +``` + +## 方法 + +| `type` | UI 参数 | 成功响应 | +|---|---|---| +| `sub2api.plugin.ready` | 无 | 无响应 | +| `config.load` | 无 | `config` | +| `config.save` | `config` 对象 | 规范化后的 `config` | +| `config.test` | 无 | `result` | +| `ui.resize` | `height` | 无响应 | +| `ui.notify` | `level`、`message` | 无响应 | + +`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。 + +## 必须执行的校验 + +UI 接收消息时必须验证 `event.source === parent`、消息来源标识、Bridge Token 和等待中的 `request_id`。每个请求必须有超时和卸载清理。 + +宿主不会向 iframe 提供管理员 Token。插件 UI 不得尝试访问管理 API、Cookie、父页面 DOM 或浏览器存储中的宿主数据。 diff --git a/backend/pkg/pluginapi/v1/manifest.schema.json b/backend/pkg/pluginapi/v1/manifest.schema.json new file mode 100644 index 0000000000..e23780ff8f --- /dev/null +++ b/backend/pkg/pluginapi/v1/manifest.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sub2api.local/schemas/plugin-manifest-v1.json", + "title": "Sub2API Plugin Manifest v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "id", "name", "version", "requires", "capabilities", "runtimes", "ui", "files"], + "properties": { + "schema_version": { "const": 1 }, + "id": { "type": "string", "maxLength": 160, "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 160 }, + "version": { "type": "string", "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" }, + "description": { "type": "string" }, + "author": { "type": "string", "maxLength": 160 }, + "requires": { + "type": "object", + "additionalProperties": false, + "required": ["sub2api", "plugin_protocol", "transport_api", "ui_bridge"], + "properties": { + "sub2api": { "type": "string", "minLength": 1 }, + "recommended_sub2api_version": { "type": "string" }, + "tested_sub2api_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "plugin_protocol": { "const": 1 }, + "transport_api": { "const": 1 }, + "ui_bridge": { "const": 1 } + } + }, + "capabilities": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "platform", "account_type"], + "properties": { + "id": { "const": "openai.oauth.outbound_transport.v1" }, + "platform": { "const": "openai" }, + "account_type": { "const": "oauth" } + } + } + }, + "runtimes": { + "type": "object", + "minProperties": 1, + "patternProperties": { + "^[a-z0-9]+-[a-z0-9]+$": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { "path": { "$ref": "#/$defs/safePath" } } + } + }, + "additionalProperties": false + }, + "ui": { + "type": "object", + "additionalProperties": false, + "required": ["entrypoint"], + "properties": { "entrypoint": { "type": "string", "pattern": "^ui/.+" } } + }, + "files": { + "type": "object", + "minProperties": 2, + "propertyNames": { "$ref": "#/$defs/safePath" }, + "additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "$defs": { + "safePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$" + } + } +} diff --git a/backend/pkg/pluginapi/v1/manifest_schema_test.go b/backend/pkg/pluginapi/v1/manifest_schema_test.go new file mode 100644 index 0000000000..4805e5300c --- /dev/null +++ b/backend/pkg/pluginapi/v1/manifest_schema_test.go @@ -0,0 +1,21 @@ +package pluginv1 + +import ( + "encoding/json" + "os" + "testing" +) + +func TestManifestSchemaIsValidJSON(t *testing.T) { + raw, err := os.ReadFile("manifest.schema.json") + if err != nil { + t.Fatalf("读取插件清单 Schema 失败: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("插件清单 Schema 不是有效 JSON: %v", err) + } + if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" { + t.Fatalf("插件清单 Schema 版本不符合预期: %v", schema["$schema"]) + } +} diff --git a/backend/pkg/pluginapi/v1/plugin.pb.go b/backend/pkg/pluginapi/v1/plugin.pb.go new file mode 100644 index 0000000000..86c6a8f667 --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin.pb.go @@ -0,0 +1,1315 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.12.4 +// source: plugin.proto + +package pluginv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoRequest) Reset() { + *x = GetInfoRequest{} + mi := &file_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoRequest) ProtoMessage() {} + +func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. +func (*GetInfoRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{0} +} + +type GetInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PluginId string `protobuf:"bytes,1,opt,name=plugin_id,json=pluginId,proto3" json:"plugin_id,omitempty"` + PluginVersion string `protobuf:"bytes,2,opt,name=plugin_version,json=pluginVersion,proto3" json:"plugin_version,omitempty"` + ProtocolVersion uint32 `protobuf:"varint,3,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + TransportApiVersion uint32 `protobuf:"varint,4,opt,name=transport_api_version,json=transportApiVersion,proto3" json:"transport_api_version,omitempty"` + Capabilities []string `protobuf:"bytes,5,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoResponse) Reset() { + *x = GetInfoResponse{} + mi := &file_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoResponse) ProtoMessage() {} + +func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. +func (*GetInfoResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{1} +} + +func (x *GetInfoResponse) GetPluginId() string { + if x != nil { + return x.PluginId + } + return "" +} + +func (x *GetInfoResponse) GetPluginVersion() string { + if x != nil { + return x.PluginVersion + } + return "" +} + +func (x *GetInfoResponse) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *GetInfoResponse) GetTransportApiVersion() uint32 { + if x != nil { + return x.TransportApiVersion + } + return 0 +} + +func (x *GetInfoResponse) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{2} +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Healthy bool `protobuf:"varint,1,opt,name=healthy,proto3" json:"healthy,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *HealthResponse) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *HealthResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type ValidateConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateConfigRequest) Reset() { + *x = ValidateConfigRequest{} + mi := &file_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateConfigRequest) ProtoMessage() {} + +func (x *ValidateConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateConfigRequest.ProtoReflect.Descriptor instead. +func (*ValidateConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *ValidateConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type ValidateConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + NormalizedConfigJson []byte `protobuf:"bytes,3,opt,name=normalized_config_json,json=normalizedConfigJson,proto3" json:"normalized_config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateConfigResponse) Reset() { + *x = ValidateConfigResponse{} + mi := &file_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateConfigResponse) ProtoMessage() {} + +func (x *ValidateConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateConfigResponse.ProtoReflect.Descriptor instead. +func (*ValidateConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{5} +} + +func (x *ValidateConfigResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *ValidateConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ValidateConfigResponse) GetNormalizedConfigJson() []byte { + if x != nil { + return x.NormalizedConfigJson + } + return nil +} + +type ApplyConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyConfigRequest) Reset() { + *x = ApplyConfigRequest{} + mi := &file_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyConfigRequest) ProtoMessage() {} + +func (x *ApplyConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyConfigRequest.ProtoReflect.Descriptor instead. +func (*ApplyConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *ApplyConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type ApplyConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Applied bool `protobuf:"varint,1,opt,name=applied,proto3" json:"applied,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyConfigResponse) Reset() { + *x = ApplyConfigResponse{} + mi := &file_plugin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyConfigResponse) ProtoMessage() {} + +func (x *ApplyConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyConfigResponse.ProtoReflect.Descriptor instead. +func (*ApplyConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{7} +} + +func (x *ApplyConfigResponse) GetApplied() bool { + if x != nil { + return x.Applied + } + return false +} + +func (x *ApplyConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type TestConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestConfigRequest) Reset() { + *x = TestConfigRequest{} + mi := &file_plugin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConfigRequest) ProtoMessage() {} + +func (x *TestConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConfigRequest.ProtoReflect.Descriptor instead. +func (*TestConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{8} +} + +func (x *TestConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type TestConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + LatencyMs int64 `protobuf:"varint,3,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestConfigResponse) Reset() { + *x = TestConfigResponse{} + mi := &file_plugin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConfigResponse) ProtoMessage() {} + +func (x *TestConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConfigResponse.ProtoReflect.Descriptor instead. +func (*TestConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{9} +} + +func (x *TestConfigResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *TestConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *TestConfigResponse) GetLatencyMs() int64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +type HeaderValues struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeaderValues) Reset() { + *x = HeaderValues{} + mi := &file_plugin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeaderValues) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeaderValues) ProtoMessage() {} + +func (x *HeaderValues) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead. +func (*HeaderValues) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{10} +} + +func (x *HeaderValues) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +type ForwardRequestStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + Host string `protobuf:"bytes,4,opt,name=host,proto3" json:"host,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,5,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ProxyUrl string `protobuf:"bytes,6,opt,name=proxy_url,json=proxyUrl,proto3" json:"proxy_url,omitempty"` + AccountId int64 `protobuf:"varint,7,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + AccountConcurrency int32 `protobuf:"varint,8,opt,name=account_concurrency,json=accountConcurrency,proto3" json:"account_concurrency,omitempty"` + Platform string `protobuf:"bytes,9,opt,name=platform,proto3" json:"platform,omitempty"` + AccountType string `protobuf:"bytes,10,opt,name=account_type,json=accountType,proto3" json:"account_type,omitempty"` + ContentLength int64 `protobuf:"varint,11,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"` + HasBody bool `protobuf:"varint,12,opt,name=has_body,json=hasBody,proto3" json:"has_body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardRequestStart) Reset() { + *x = ForwardRequestStart{} + mi := &file_plugin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardRequestStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardRequestStart) ProtoMessage() {} + +func (x *ForwardRequestStart) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardRequestStart.ProtoReflect.Descriptor instead. +func (*ForwardRequestStart) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{11} +} + +func (x *ForwardRequestStart) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ForwardRequestStart) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *ForwardRequestStart) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ForwardRequestStart) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ForwardRequestStart) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ForwardRequestStart) GetProxyUrl() string { + if x != nil { + return x.ProxyUrl + } + return "" +} + +func (x *ForwardRequestStart) GetAccountId() int64 { + if x != nil { + return x.AccountId + } + return 0 +} + +func (x *ForwardRequestStart) GetAccountConcurrency() int32 { + if x != nil { + return x.AccountConcurrency + } + return 0 +} + +func (x *ForwardRequestStart) GetPlatform() string { + if x != nil { + return x.Platform + } + return "" +} + +func (x *ForwardRequestStart) GetAccountType() string { + if x != nil { + return x.AccountType + } + return "" +} + +func (x *ForwardRequestStart) GetContentLength() int64 { + if x != nil { + return x.ContentLength + } + return 0 +} + +func (x *ForwardRequestStart) GetHasBody() bool { + if x != nil { + return x.HasBody + } + return false +} + +type ForwardRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ForwardRequest_Start + // *ForwardRequest_BodyChunk + // *ForwardRequest_BodyEnd + Frame isForwardRequest_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardRequest) Reset() { + *x = ForwardRequest{} + mi := &file_plugin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardRequest) ProtoMessage() {} + +func (x *ForwardRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardRequest.ProtoReflect.Descriptor instead. +func (*ForwardRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{12} +} + +func (x *ForwardRequest) GetFrame() isForwardRequest_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ForwardRequest) GetStart() *ForwardRequestStart { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ForwardRequest) GetBodyChunk() []byte { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_BodyChunk); ok { + return x.BodyChunk + } + } + return nil +} + +func (x *ForwardRequest) GetBodyEnd() bool { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_BodyEnd); ok { + return x.BodyEnd + } + } + return false +} + +type isForwardRequest_Frame interface { + isForwardRequest_Frame() +} + +type ForwardRequest_Start struct { + Start *ForwardRequestStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ForwardRequest_BodyChunk struct { + BodyChunk []byte `protobuf:"bytes,2,opt,name=body_chunk,json=bodyChunk,proto3,oneof"` +} + +type ForwardRequest_BodyEnd struct { + BodyEnd bool `protobuf:"varint,3,opt,name=body_end,json=bodyEnd,proto3,oneof"` +} + +func (*ForwardRequest_Start) isForwardRequest_Frame() {} + +func (*ForwardRequest_BodyChunk) isForwardRequest_Frame() {} + +func (*ForwardRequest_BodyEnd) isForwardRequest_Frame() {} + +type ForwardResponseStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + ProtocolMajor int32 `protobuf:"varint,4,opt,name=protocol_major,json=protocolMajor,proto3" json:"protocol_major,omitempty"` + ProtocolMinor int32 `protobuf:"varint,5,opt,name=protocol_minor,json=protocolMinor,proto3" json:"protocol_minor,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ContentLength int64 `protobuf:"varint,7,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseStart) Reset() { + *x = ForwardResponseStart{} + mi := &file_plugin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseStart) ProtoMessage() {} + +func (x *ForwardResponseStart) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseStart.ProtoReflect.Descriptor instead. +func (*ForwardResponseStart) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{13} +} + +func (x *ForwardResponseStart) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *ForwardResponseStart) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ForwardResponseStart) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *ForwardResponseStart) GetProtocolMajor() int32 { + if x != nil { + return x.ProtocolMajor + } + return 0 +} + +func (x *ForwardResponseStart) GetProtocolMinor() int32 { + if x != nil { + return x.ProtocolMinor + } + return 0 +} + +func (x *ForwardResponseStart) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ForwardResponseStart) GetContentLength() int64 { + if x != nil { + return x.ContentLength + } + return 0 +} + +type ForwardResponseEnd struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesReceived int64 `protobuf:"varint,1,opt,name=bytes_received,json=bytesReceived,proto3" json:"bytes_received,omitempty"` + DurationMs int64 `protobuf:"varint,2,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseEnd) Reset() { + *x = ForwardResponseEnd{} + mi := &file_plugin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseEnd) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseEnd) ProtoMessage() {} + +func (x *ForwardResponseEnd) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseEnd.ProtoReflect.Descriptor instead. +func (*ForwardResponseEnd) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{14} +} + +func (x *ForwardResponseEnd) GetBytesReceived() int64 { + if x != nil { + return x.BytesReceived + } + return 0 +} + +func (x *ForwardResponseEnd) GetDurationMs() int64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +type ForwardResponseError struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + RequestSent bool `protobuf:"varint,3,opt,name=request_sent,json=requestSent,proto3" json:"request_sent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseError) Reset() { + *x = ForwardResponseError{} + mi := &file_plugin_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseError) ProtoMessage() {} + +func (x *ForwardResponseError) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseError.ProtoReflect.Descriptor instead. +func (*ForwardResponseError) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{15} +} + +func (x *ForwardResponseError) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ForwardResponseError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ForwardResponseError) GetRequestSent() bool { + if x != nil { + return x.RequestSent + } + return false +} + +type ForwardResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ForwardResponse_Start + // *ForwardResponse_BodyChunk + // *ForwardResponse_End + // *ForwardResponse_Error + Frame isForwardResponse_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponse) Reset() { + *x = ForwardResponse{} + mi := &file_plugin_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponse) ProtoMessage() {} + +func (x *ForwardResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponse.ProtoReflect.Descriptor instead. +func (*ForwardResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{16} +} + +func (x *ForwardResponse) GetFrame() isForwardResponse_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ForwardResponse) GetStart() *ForwardResponseStart { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ForwardResponse) GetBodyChunk() []byte { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_BodyChunk); ok { + return x.BodyChunk + } + } + return nil +} + +func (x *ForwardResponse) GetEnd() *ForwardResponseEnd { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_End); ok { + return x.End + } + } + return nil +} + +func (x *ForwardResponse) GetError() *ForwardResponseError { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_Error); ok { + return x.Error + } + } + return nil +} + +type isForwardResponse_Frame interface { + isForwardResponse_Frame() +} + +type ForwardResponse_Start struct { + Start *ForwardResponseStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ForwardResponse_BodyChunk struct { + BodyChunk []byte `protobuf:"bytes,2,opt,name=body_chunk,json=bodyChunk,proto3,oneof"` +} + +type ForwardResponse_End struct { + End *ForwardResponseEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ForwardResponse_Error struct { + Error *ForwardResponseError `protobuf:"bytes,4,opt,name=error,proto3,oneof"` +} + +func (*ForwardResponse_Start) isForwardResponse_Frame() {} + +func (*ForwardResponse_BodyChunk) isForwardResponse_Frame() {} + +func (*ForwardResponse_End) isForwardResponse_Frame() {} + +func (*ForwardResponse_Error) isForwardResponse_Frame() {} + +var File_plugin_proto protoreflect.FileDescriptor + +const file_plugin_proto_rawDesc = "" + + "\n" + + "\fplugin.proto\x12\x11sub2api.plugin.v1\"\x10\n" + + "\x0eGetInfoRequest\"\xd8\x01\n" + + "\x0fGetInfoResponse\x12\x1b\n" + + "\tplugin_id\x18\x01 \x01(\tR\bpluginId\x12%\n" + + "\x0eplugin_version\x18\x02 \x01(\tR\rpluginVersion\x12)\n" + + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x122\n" + + "\x15transport_api_version\x18\x04 \x01(\rR\x13transportApiVersion\x12\"\n" + + "\fcapabilities\x18\x05 \x03(\tR\fcapabilities\"\x0f\n" + + "\rHealthRequest\"D\n" + + "\x0eHealthResponse\x12\x18\n" + + "\ahealthy\x18\x01 \x01(\bR\ahealthy\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"8\n" + + "\x15ValidateConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"~\n" + + "\x16ValidateConfigResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x124\n" + + "\x16normalized_config_json\x18\x03 \x01(\fR\x14normalizedConfigJson\"5\n" + + "\x12ApplyConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"I\n" + + "\x13ApplyConfigResponse\x12\x18\n" + + "\aapplied\x18\x01 \x01(\bR\aapplied\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"4\n" + + "\x11TestConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"g\n" + + "\x12TestConfigResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + + "\n" + + "latency_ms\x18\x03 \x01(\x03R\tlatencyMs\"&\n" + + "\fHeaderValues\x12\x16\n" + + "\x06values\x18\x01 \x03(\tR\x06values\"\x8c\x04\n" + + "\x13ForwardRequestStart\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" + + "\x06method\x18\x02 \x01(\tR\x06method\x12\x10\n" + + "\x03url\x18\x03 \x01(\tR\x03url\x12\x12\n" + + "\x04host\x18\x04 \x01(\tR\x04host\x12M\n" + + "\aheaders\x18\x05 \x03(\v23.sub2api.plugin.v1.ForwardRequestStart.HeadersEntryR\aheaders\x12\x1b\n" + + "\tproxy_url\x18\x06 \x01(\tR\bproxyUrl\x12\x1d\n" + + "\n" + + "account_id\x18\a \x01(\x03R\taccountId\x12/\n" + + "\x13account_concurrency\x18\b \x01(\x05R\x12accountConcurrency\x12\x1a\n" + + "\bplatform\x18\t \x01(\tR\bplatform\x12!\n" + + "\faccount_type\x18\n" + + " \x01(\tR\vaccountType\x12%\n" + + "\x0econtent_length\x18\v \x01(\x03R\rcontentLength\x12\x19\n" + + "\bhas_body\x18\f \x01(\bR\ahasBody\x1a[\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.sub2api.plugin.v1.HeaderValuesR\x05value:\x028\x01\"\x97\x01\n" + + "\x0eForwardRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.sub2api.plugin.v1.ForwardRequestStartH\x00R\x05start\x12\x1f\n" + + "\n" + + "body_chunk\x18\x02 \x01(\fH\x00R\tbodyChunk\x12\x1b\n" + + "\bbody_end\x18\x03 \x01(\bH\x00R\abodyEndB\a\n" + + "\x05frame\"\x8d\x03\n" + + "\x14ForwardResponseStart\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\x05R\n" + + "statusCode\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1a\n" + + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12%\n" + + "\x0eprotocol_major\x18\x04 \x01(\x05R\rprotocolMajor\x12%\n" + + "\x0eprotocol_minor\x18\x05 \x01(\x05R\rprotocolMinor\x12N\n" + + "\aheaders\x18\x06 \x03(\v24.sub2api.plugin.v1.ForwardResponseStart.HeadersEntryR\aheaders\x12%\n" + + "\x0econtent_length\x18\a \x01(\x03R\rcontentLength\x1a[\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.sub2api.plugin.v1.HeaderValuesR\x05value:\x028\x01\"\\\n" + + "\x12ForwardResponseEnd\x12%\n" + + "\x0ebytes_received\x18\x01 \x01(\x03R\rbytesReceived\x12\x1f\n" + + "\vduration_ms\x18\x02 \x01(\x03R\n" + + "durationMs\"g\n" + + "\x14ForwardResponseError\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12!\n" + + "\frequest_sent\x18\x03 \x01(\bR\vrequestSent\"\xf8\x01\n" + + "\x0fForwardResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.sub2api.plugin.v1.ForwardResponseStartH\x00R\x05start\x12\x1f\n" + + "\n" + + "body_chunk\x18\x02 \x01(\fH\x00R\tbodyChunk\x129\n" + + "\x03end\x18\x03 \x01(\v2%.sub2api.plugin.v1.ForwardResponseEndH\x00R\x03end\x12?\n" + + "\x05error\x18\x04 \x01(\v2'.sub2api.plugin.v1.ForwardResponseErrorH\x00R\x05errorB\a\n" + + "\x05frame2\xa8\x04\n" + + "\x0fTransportPlugin\x12P\n" + + "\aGetInfo\x12!.sub2api.plugin.v1.GetInfoRequest\x1a\".sub2api.plugin.v1.GetInfoResponse\x12M\n" + + "\x06Health\x12 .sub2api.plugin.v1.HealthRequest\x1a!.sub2api.plugin.v1.HealthResponse\x12e\n" + + "\x0eValidateConfig\x12(.sub2api.plugin.v1.ValidateConfigRequest\x1a).sub2api.plugin.v1.ValidateConfigResponse\x12\\\n" + + "\vApplyConfig\x12%.sub2api.plugin.v1.ApplyConfigRequest\x1a&.sub2api.plugin.v1.ApplyConfigResponse\x12Y\n" + + "\n" + + "TestConfig\x12$.sub2api.plugin.v1.TestConfigRequest\x1a%.sub2api.plugin.v1.TestConfigResponse\x12T\n" + + "\aForward\x12!.sub2api.plugin.v1.ForwardRequest\x1a\".sub2api.plugin.v1.ForwardResponse(\x010\x01B7Z5github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1b\x06proto3" + +var ( + file_plugin_proto_rawDescOnce sync.Once + file_plugin_proto_rawDescData []byte +) + +func file_plugin_proto_rawDescGZIP() []byte { + file_plugin_proto_rawDescOnce.Do(func() { + file_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc))) + }) + return file_plugin_proto_rawDescData +} + +var file_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_plugin_proto_goTypes = []any{ + (*GetInfoRequest)(nil), // 0: sub2api.plugin.v1.GetInfoRequest + (*GetInfoResponse)(nil), // 1: sub2api.plugin.v1.GetInfoResponse + (*HealthRequest)(nil), // 2: sub2api.plugin.v1.HealthRequest + (*HealthResponse)(nil), // 3: sub2api.plugin.v1.HealthResponse + (*ValidateConfigRequest)(nil), // 4: sub2api.plugin.v1.ValidateConfigRequest + (*ValidateConfigResponse)(nil), // 5: sub2api.plugin.v1.ValidateConfigResponse + (*ApplyConfigRequest)(nil), // 6: sub2api.plugin.v1.ApplyConfigRequest + (*ApplyConfigResponse)(nil), // 7: sub2api.plugin.v1.ApplyConfigResponse + (*TestConfigRequest)(nil), // 8: sub2api.plugin.v1.TestConfigRequest + (*TestConfigResponse)(nil), // 9: sub2api.plugin.v1.TestConfigResponse + (*HeaderValues)(nil), // 10: sub2api.plugin.v1.HeaderValues + (*ForwardRequestStart)(nil), // 11: sub2api.plugin.v1.ForwardRequestStart + (*ForwardRequest)(nil), // 12: sub2api.plugin.v1.ForwardRequest + (*ForwardResponseStart)(nil), // 13: sub2api.plugin.v1.ForwardResponseStart + (*ForwardResponseEnd)(nil), // 14: sub2api.plugin.v1.ForwardResponseEnd + (*ForwardResponseError)(nil), // 15: sub2api.plugin.v1.ForwardResponseError + (*ForwardResponse)(nil), // 16: sub2api.plugin.v1.ForwardResponse + nil, // 17: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry + nil, // 18: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry +} +var file_plugin_proto_depIdxs = []int32{ + 17, // 0: sub2api.plugin.v1.ForwardRequestStart.headers:type_name -> sub2api.plugin.v1.ForwardRequestStart.HeadersEntry + 11, // 1: sub2api.plugin.v1.ForwardRequest.start:type_name -> sub2api.plugin.v1.ForwardRequestStart + 18, // 2: sub2api.plugin.v1.ForwardResponseStart.headers:type_name -> sub2api.plugin.v1.ForwardResponseStart.HeadersEntry + 13, // 3: sub2api.plugin.v1.ForwardResponse.start:type_name -> sub2api.plugin.v1.ForwardResponseStart + 14, // 4: sub2api.plugin.v1.ForwardResponse.end:type_name -> sub2api.plugin.v1.ForwardResponseEnd + 15, // 5: sub2api.plugin.v1.ForwardResponse.error:type_name -> sub2api.plugin.v1.ForwardResponseError + 10, // 6: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues + 10, // 7: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues + 0, // 8: sub2api.plugin.v1.TransportPlugin.GetInfo:input_type -> sub2api.plugin.v1.GetInfoRequest + 2, // 9: sub2api.plugin.v1.TransportPlugin.Health:input_type -> sub2api.plugin.v1.HealthRequest + 4, // 10: sub2api.plugin.v1.TransportPlugin.ValidateConfig:input_type -> sub2api.plugin.v1.ValidateConfigRequest + 6, // 11: sub2api.plugin.v1.TransportPlugin.ApplyConfig:input_type -> sub2api.plugin.v1.ApplyConfigRequest + 8, // 12: sub2api.plugin.v1.TransportPlugin.TestConfig:input_type -> sub2api.plugin.v1.TestConfigRequest + 12, // 13: sub2api.plugin.v1.TransportPlugin.Forward:input_type -> sub2api.plugin.v1.ForwardRequest + 1, // 14: sub2api.plugin.v1.TransportPlugin.GetInfo:output_type -> sub2api.plugin.v1.GetInfoResponse + 3, // 15: sub2api.plugin.v1.TransportPlugin.Health:output_type -> sub2api.plugin.v1.HealthResponse + 5, // 16: sub2api.plugin.v1.TransportPlugin.ValidateConfig:output_type -> sub2api.plugin.v1.ValidateConfigResponse + 7, // 17: sub2api.plugin.v1.TransportPlugin.ApplyConfig:output_type -> sub2api.plugin.v1.ApplyConfigResponse + 9, // 18: sub2api.plugin.v1.TransportPlugin.TestConfig:output_type -> sub2api.plugin.v1.TestConfigResponse + 16, // 19: sub2api.plugin.v1.TransportPlugin.Forward:output_type -> sub2api.plugin.v1.ForwardResponse + 14, // [14:20] is the sub-list for method output_type + 8, // [8:14] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_plugin_proto_init() } +func file_plugin_proto_init() { + if File_plugin_proto != nil { + return + } + file_plugin_proto_msgTypes[12].OneofWrappers = []any{ + (*ForwardRequest_Start)(nil), + (*ForwardRequest_BodyChunk)(nil), + (*ForwardRequest_BodyEnd)(nil), + } + file_plugin_proto_msgTypes[16].OneofWrappers = []any{ + (*ForwardResponse_Start)(nil), + (*ForwardResponse_BodyChunk)(nil), + (*ForwardResponse_End)(nil), + (*ForwardResponse_Error)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc)), + NumEnums: 0, + NumMessages: 19, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_plugin_proto_goTypes, + DependencyIndexes: file_plugin_proto_depIdxs, + MessageInfos: file_plugin_proto_msgTypes, + }.Build() + File_plugin_proto = out.File + file_plugin_proto_goTypes = nil + file_plugin_proto_depIdxs = nil +} diff --git a/backend/pkg/pluginapi/v1/plugin.proto b/backend/pkg/pluginapi/v1/plugin.proto new file mode 100644 index 0000000000..e4b8fdac3a --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin.proto @@ -0,0 +1,117 @@ +syntax = "proto3"; + +package sub2api.plugin.v1; + +option go_package = "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1"; + +service TransportPlugin { + rpc GetInfo(GetInfoRequest) returns (GetInfoResponse); + rpc Health(HealthRequest) returns (HealthResponse); + rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); + rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse); + rpc TestConfig(TestConfigRequest) returns (TestConfigResponse); + rpc Forward(stream ForwardRequest) returns (stream ForwardResponse); +} + +message GetInfoRequest {} + +message GetInfoResponse { + string plugin_id = 1; + string plugin_version = 2; + uint32 protocol_version = 3; + uint32 transport_api_version = 4; + repeated string capabilities = 5; +} + +message HealthRequest {} + +message HealthResponse { + bool healthy = 1; + string message = 2; +} + +message ValidateConfigRequest { + bytes config_json = 1; +} + +message ValidateConfigResponse { + bool valid = 1; + string message = 2; + bytes normalized_config_json = 3; +} + +message ApplyConfigRequest { + bytes config_json = 1; +} + +message ApplyConfigResponse { + bool applied = 1; + string message = 2; +} + +message TestConfigRequest { + bytes config_json = 1; +} + +message TestConfigResponse { + bool success = 1; + string message = 2; + int64 latency_ms = 3; +} + +message HeaderValues { + repeated string values = 1; +} + +message ForwardRequestStart { + string request_id = 1; + string method = 2; + string url = 3; + string host = 4; + map headers = 5; + string proxy_url = 6; + int64 account_id = 7; + int32 account_concurrency = 8; + string platform = 9; + string account_type = 10; + int64 content_length = 11; + bool has_body = 12; +} + +message ForwardRequest { + oneof frame { + ForwardRequestStart start = 1; + bytes body_chunk = 2; + bool body_end = 3; + } +} + +message ForwardResponseStart { + int32 status_code = 1; + string status = 2; + string protocol = 3; + int32 protocol_major = 4; + int32 protocol_minor = 5; + map headers = 6; + int64 content_length = 7; +} + +message ForwardResponseEnd { + int64 bytes_received = 1; + int64 duration_ms = 2; +} + +message ForwardResponseError { + string code = 1; + string message = 2; + bool request_sent = 3; +} + +message ForwardResponse { + oneof frame { + ForwardResponseStart start = 1; + bytes body_chunk = 2; + ForwardResponseEnd end = 3; + ForwardResponseError error = 4; + } +} diff --git a/backend/pkg/pluginapi/v1/plugin_grpc.pb.go b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go new file mode 100644 index 0000000000..ee07e1b1dd --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go @@ -0,0 +1,306 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.12.4 +// source: plugin.proto + +package pluginv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo" + TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health" + TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig" + TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig" + TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig" + TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward" +) + +// TransportPluginClient is the client API for TransportPlugin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type TransportPluginClient interface { + GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) + ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error) + ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error) + TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error) + Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error) +} + +type transportPluginClient struct { + cc grpc.ClientConnInterface +} + +func NewTransportPluginClient(cc grpc.ClientConnInterface) TransportPluginClient { + return &transportPluginClient{cc} +} + +func (c *transportPluginClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInfoResponse) + err := c.cc.Invoke(ctx, TransportPlugin_GetInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, TransportPlugin_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidateConfigResponse) + err := c.cc.Invoke(ctx, TransportPlugin_ValidateConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApplyConfigResponse) + err := c.cc.Invoke(ctx, TransportPlugin_ApplyConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TestConfigResponse) + err := c.cc.Invoke(ctx, TransportPlugin_TestConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &TransportPlugin_ServiceDesc.Streams[0], TransportPlugin_Forward_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ForwardRequest, ForwardResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TransportPlugin_ForwardClient = grpc.BidiStreamingClient[ForwardRequest, ForwardResponse] + +// TransportPluginServer is the server API for TransportPlugin service. +// All implementations must embed UnimplementedTransportPluginServer +// for forward compatibility. +type TransportPluginServer interface { + GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) + Health(context.Context, *HealthRequest) (*HealthResponse, error) + ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error) + ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error) + TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error) + Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error + mustEmbedUnimplementedTransportPluginServer() +} + +// UnimplementedTransportPluginServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTransportPluginServer struct{} + +func (UnimplementedTransportPluginServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented") +} +func (UnimplementedTransportPluginServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedTransportPluginServer) ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ValidateConfig not implemented") +} +func (UnimplementedTransportPluginServer) ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApplyConfig not implemented") +} +func (UnimplementedTransportPluginServer) TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method TestConfig not implemented") +} +func (UnimplementedTransportPluginServer) Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error { + return status.Error(codes.Unimplemented, "method Forward not implemented") +} +func (UnimplementedTransportPluginServer) mustEmbedUnimplementedTransportPluginServer() {} +func (UnimplementedTransportPluginServer) testEmbeddedByValue() {} + +// UnsafeTransportPluginServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TransportPluginServer will +// result in compilation errors. +type UnsafeTransportPluginServer interface { + mustEmbedUnimplementedTransportPluginServer() +} + +func RegisterTransportPluginServer(s grpc.ServiceRegistrar, srv TransportPluginServer) { + // If the following call panics, it indicates UnimplementedTransportPluginServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&TransportPlugin_ServiceDesc, srv) +} + +func _TransportPlugin_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInfoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).GetInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_GetInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).GetInfo(ctx, req.(*GetInfoRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_ValidateConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).ValidateConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_ValidateConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).ValidateConfig(ctx, req.(*ValidateConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_ApplyConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApplyConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).ApplyConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_ApplyConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).ApplyConfig(ctx, req.(*ApplyConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_TestConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TestConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).TestConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_TestConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).TestConfig(ctx, req.(*TestConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_Forward_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(TransportPluginServer).Forward(&grpc.GenericServerStream[ForwardRequest, ForwardResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TransportPlugin_ForwardServer = grpc.BidiStreamingServer[ForwardRequest, ForwardResponse] + +// TransportPlugin_ServiceDesc is the grpc.ServiceDesc for TransportPlugin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TransportPlugin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sub2api.plugin.v1.TransportPlugin", + HandlerType: (*TransportPluginServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetInfo", + Handler: _TransportPlugin_GetInfo_Handler, + }, + { + MethodName: "Health", + Handler: _TransportPlugin_Health_Handler, + }, + { + MethodName: "ValidateConfig", + Handler: _TransportPlugin_ValidateConfig_Handler, + }, + { + MethodName: "ApplyConfig", + Handler: _TransportPlugin_ApplyConfig_Handler, + }, + { + MethodName: "TestConfig", + Handler: _TransportPlugin_TestConfig_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Forward", + Handler: _TransportPlugin_Forward_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "plugin.proto", +} diff --git a/backend/pkg/pluginapi/v1/runtime.go b/backend/pkg/pluginapi/v1/runtime.go new file mode 100644 index 0000000000..662519c371 --- /dev/null +++ b/backend/pkg/pluginapi/v1/runtime.go @@ -0,0 +1,59 @@ +package pluginv1 + +import ( + "context" + + hcplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" +) + +const ( + // ProtocolVersion 是宿主与插件进程握手协议版本。 + ProtocolVersion = 1 + // TransportAPIVersion 是 OpenAI OAuth 出站传输契约版本。 + TransportAPIVersion = 1 + // UIBridgeVersion 是插件管理页与沙箱 UI 的消息协议版本。 + UIBridgeVersion = 1 + // TransportPluginName 是 go-plugin 中注册的唯一能力名称。 + TransportPluginName = "oauth_transport" +) + +// HandshakeConfig 防止普通可执行文件被误当成 Sub2API 插件启动。 +var HandshakeConfig = hcplugin.HandshakeConfig{ + ProtocolVersion: ProtocolVersion, + MagicCookieKey: "SUB2API_PLUGIN_MAGIC_COOKIE", + MagicCookieValue: "sub2api-plugin-v1", +} + +// GRPCPlugin 把生成的 gRPC 服务注册到 go-plugin 子进程。 +type GRPCPlugin struct { + hcplugin.NetRPCUnsupportedPlugin + Impl TransportPluginServer +} + +func (p *GRPCPlugin) GRPCServer(_ *hcplugin.GRPCBroker, server *grpc.Server) error { + RegisterTransportPluginServer(server, p.Impl) + return nil +} + +func (p *GRPCPlugin) GRPCClient(_ context.Context, _ *hcplugin.GRPCBroker, conn *grpc.ClientConn) (any, error) { + return NewTransportPluginClient(conn), nil +} + +// ClientPluginMap 返回宿主侧使用的插件声明。 +func ClientPluginMap() map[string]hcplugin.Plugin { + return map[string]hcplugin.Plugin{ + TransportPluginName: &GRPCPlugin{}, + } +} + +// Serve 启动一个实现了传输协议的插件进程。 +func Serve(impl TransportPluginServer) { + hcplugin.Serve(&hcplugin.ServeConfig{ + HandshakeConfig: HandshakeConfig, + Plugins: map[string]hcplugin.Plugin{ + TransportPluginName: &GRPCPlugin{Impl: impl}, + }, + GRPCServer: hcplugin.DefaultGRPCServer, + }) +} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 7dc445e3fc..490e768f27 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -5162,12 +5162,12 @@ "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-6, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 12.5e-6, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -5177,7 +5177,7 @@ "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 75e-6, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5210,12 +5210,12 @@ "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-6, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 12.5e-6, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -5225,7 +5225,7 @@ "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 75e-6, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", diff --git a/deploy/.env.example b/deploy/.env.example index 6beca88308..76762e303b 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -285,6 +285,11 @@ GATEWAY_FORCE_CODEX_CLI=false GATEWAY_OPENAI_COMPACT_MODEL=gpt-5.4 # OpenAI/Codex 等待上游响应头超时(秒);0 表示不使用本地响应头超时截断。 GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=0 +# 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket。 +# 当代理或网络导致上游 WebSocket 反复重连时可设为 true;这不会禁用 HTTP/2。 +# 如需回退 HTTP/1.1,请另行设置 GATEWAY_OPENAI_HTTP2_ENABLED=false。 +# 将此项保存在持久化 .env 中,镜像更新或容器重建后会在启动时重新读取。 +GATEWAY_OPENAI_WS_FORCE_HTTP=false # OpenAI HTTP 上游默认启用 HTTP/2;如需紧急回滚可设为 false。 GATEWAY_OPENAI_HTTP2_ENABLED=true GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=true diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 325520f308..ab312c4852 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.5-alpine +ARG GOLANG_IMAGE=golang:1.27.0-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 095c550993..033cb29908 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -181,11 +181,21 @@ security: # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) # Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security # 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全 - policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) insecure_skip_verify: false + # Optional ordered probe targets. Leave empty to use the built-in ip-api/ipify fallback. + # parser supports: ip-api, ipify, chatgpt-trace + # 可选的有序探测目标。留空时使用内置 ip-api/ipify 回退。 + # parser 支持:ip-api、ipify、chatgpt-trace + urls: [] + # urls: + # - url: "https://chatgpt.com/cdn-cgi/trace" + # parser: "chatgpt-trace" + # - url: "https://api64.ipify.org?format=json" + # parser: "ipify" proxy_fallback: # Allow auxiliary services (update check, pricing data) to fallback to direct # connection when proxy initialization fails. Does NOT affect AI gateway connections. @@ -204,6 +214,9 @@ gateway: # OpenAI/Codex upstream response header timeout (seconds, 0=disabled) # OpenAI/Codex 等待上游响应头超时时间(秒,0=禁用本地响应头超时) openai_response_header_timeout: 0 + # Grok text Responses first-byte timeout (seconds, 0=disabled; default 120) + # Grok 文本 Responses 首字节超时时间(秒,0=禁用;默认 120) + grok_response_header_timeout: 120 # Native OpenAI HTTP Responses first semantic output timeout (seconds, 0=disabled) # Includes response-header wait; does not apply to passthrough or WebSocket transports. # A timed-out request may already have incurred upstream usage; account failover can therefore duplicate upstream billing. @@ -222,39 +235,15 @@ gateway: # Max bytes to read for non-stream upstream responses (default: 8MB) # 非流式上游响应体读取上限(默认 8MB) upstream_response_read_max_bytes: 8388608 + # Max bytes to read for upstream model-list responses (default: 8MB) + # 上游模型列表响应体读取上限(默认 8MB) + models_list_read_max_bytes: 8388608 # Max bytes to read for proxy probe responses (default: 1MB) # 代理探测响应体读取上限(默认 1MB) proxy_probe_response_read_max_bytes: 1048576 # Enable Gemini upstream response header debug logs (default: false) # 是否开启 Gemini 上游响应头调试日志(默认 false) gemini_debug_response_headers: false - # Sora max request body size in bytes (0=use max_body_size) - # Sora 请求体最大字节数(0=使用 max_body_size) - sora_max_body_size: 268435456 - # Sora stream timeout (seconds, 0=disable) - # Sora 流式请求总超时(秒,0=禁用) - sora_stream_timeout_seconds: 900 - # Sora non-stream timeout (seconds, 0=disable) - # Sora 非流式请求超时(秒,0=禁用) - sora_request_timeout_seconds: 180 - # Sora stream enforcement mode: force/error - # Sora stream 强制策略:force/error - sora_stream_mode: "force" - # Sora model filters - # Sora 模型过滤配置 - sora_model_filters: - # Hide prompt-enhance models by default - # 默认隐藏 prompt-enhance 模型 - hide_prompt_enhance: true - # Require API key for /sora/media proxy (default: false) - # /sora/media 是否强制要求 API Key(默认 true) - sora_media_require_api_key: true - # Sora media temporary signing key (empty disables signed URL) - # Sora 媒体临时签名密钥(为空则禁用签名) - sora_media_signing_key: "" - # Signed URL TTL seconds (<=0 disables) - # 临时签名 URL 有效期(秒,<=0 表示禁用) - sora_media_signed_url_ttl_seconds: 900 # Connection pool isolation strategy: # 连接池隔离策略: # - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts) @@ -343,7 +332,8 @@ gateway: # 按账号类型细分开关 oauth_enabled: true apikey_enabled: true - # 全局强制 HTTP(紧急回滚开关) + # 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket(紧急回滚开关)。 + # 此开关不强制 HTTP/1.1;HTTP/2 由 gateway.openai_http2 单独控制。 force_http: false # 允许在 WSv2 下按策略恢复 store=true(默认 false) allow_store_recovery: false @@ -649,112 +639,9 @@ log: # 之后每 N 条保留 1 条 thereafter: 100 -# ============================================================================= -# Sora Direct Client Configuration -# Sora 直连配置 -# ============================================================================= -sora: - client: - # Sora backend base URL - # Sora 上游 Base URL - base_url: "https://sora.chatgpt.com/backend" - # Request timeout (seconds) - # 请求超时(秒) - timeout_seconds: 120 - # Max retries for upstream requests - # 上游请求最大重试次数 - max_retries: 3 - # Account+proxy cooldown window after Cloudflare challenge (seconds, 0 to disable) - # Cloudflare challenge 后按账号+代理冷却窗口(秒,0 表示关闭) - cloudflare_challenge_cooldown_seconds: 900 - # Poll interval (seconds) - # 轮询间隔(秒) - poll_interval_seconds: 2 - # Max poll attempts - # 最大轮询次数 - max_poll_attempts: 600 - # Recent task query limit (image) - # 最近任务查询数量(图片轮询) - recent_task_limit: 50 - # Recent task query max limit (fallback) - # 最近任务查询最大数量(回退) - recent_task_limit_max: 200 - # Enable debug logs for Sora upstream requests - # 启用 Sora 直连调试日志 - # 调试日志会输出上游请求尝试、重试、响应摘要;Authorization/openai-sentinel-token 等敏感头会自动脱敏 - debug: false - # Allow Sora client to fetch token via OpenAI token provider - # 是否允许 Sora 客户端通过 OpenAI token provider 取 token(默认 false,避免误走 OpenAI 刷新链路) - use_openai_token_provider: false - # Optional custom headers (key-value) - # 额外请求头(键值对) - headers: {} - # Default User-Agent for Sora requests - # Sora 默认 User-Agent - user_agent: "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)" - # Disable TLS fingerprint for Sora upstream - # 关闭 Sora 上游 TLS 指纹伪装 - disable_tls_fingerprint: false - # curl_cffi sidecar for Sora only (required) - # 仅 Sora 链路使用的 curl_cffi sidecar(必需) - curl_cffi_sidecar: - # Sora 强制通过 sidecar 请求,必须启用 - # Sora is forced to use sidecar only; keep enabled=true - enabled: true - # Sidecar base URL (default endpoint: /request) - # sidecar 基础地址(默认请求端点:/request) - base_url: "http://sora-curl-cffi-sidecar:8080" - # curl_cffi impersonate profile, e.g. chrome131/chrome124/safari18_0 - # curl_cffi 指纹伪装 profile,例如 chrome131/chrome124/safari18_0 - impersonate: "chrome131" - # Sidecar request timeout (seconds) - # sidecar 请求超时(秒) - timeout_seconds: 60 - # Reuse session key per account+proxy to let sidecar persist cookies/session - # 按账号+代理复用 session key,让 sidecar 持久化 cookies/session - session_reuse_enabled: true - # Session TTL in sidecar (seconds) - # sidecar 会话 TTL(秒) - session_ttl_seconds: 3600 - storage: - # Storage type (local only for now) - # 存储类型(首发仅支持 local) - type: "local" - # Local base path; empty uses /app/data/sora - # 本地存储基础路径;为空使用 /app/data/sora - local_path: "" - # Fallback to upstream URL when download fails - # 下载失败时回退到上游 URL - fallback_to_upstream: true - # Max concurrent downloads - # 并发下载上限 - max_concurrent_downloads: 4 - # Download timeout (seconds) - # 下载超时(秒) - download_timeout_seconds: 120 - # Max download bytes - # 最大下载字节数 - max_download_bytes: 209715200 - # Enable debug logs for media storage - # 启用媒体存储调试日志 - debug: false - cleanup: - # Enable cleanup task - # 启用清理任务 - enabled: true - # Retention days - # 保留天数 - retention_days: 7 - # Cron schedule - # Cron 调度表达式 - schedule: "0 3 * * *" - # Token refresh behavior # token 刷新行为控制 token_refresh: - # Whether OpenAI refresh flow is allowed to sync linked Sora accounts - # 是否允许 OpenAI 刷新流程同步覆盖 linked_openai_account_id 关联的 Sora 账号 token - sync_linked_sora_accounts: false # Candidate accounts loaded per cursor page (maximum 1000) # 每个游标分页加载的候选账号数量(最大 1000) candidate_page_size: 200 @@ -1142,6 +1029,26 @@ rate_limit: # 上游返回 529(过载)时的冷却时间(分钟) overload_cooldown_minutes: 10 +# ============================================================================= +# Local OAuth Transport Plugins (Optional) +# 本地 OAuth 出站传输插件(可选) +# ============================================================================= +plugins: + # Empty means {{DATA_DIR}}/plugins, or ./data/plugins when DATA_DIR is unset. + # 留空时使用 {{DATA_DIR}}/plugins;未设置 DATA_DIR 时使用 ./data/plugins。 + data_dir: "" + # Keep false in production. Enable only for local development packages you built yourself. + # 生产环境应保持 false;仅调试自己构建的本地插件时临时开启。 + allow_unsigned: false + # Additional Ed25519 public keys indexed by signature.json key_id. + # OpenAI Transport's public key is built in; this map only adds third-party publishers. + # 以 signature.json 中 key_id 为索引的额外 Ed25519 公钥。 + # OpenAI Transport 公钥已内置,此处只需添加第三方发布者。 + trusted_publishers: {} + max_upload_bytes: 134217728 + max_uncompressed_bytes: 268435456 + start_timeout_seconds: 15 + # ============================================================================= # Pricing Data Source (Optional) # 定价数据源(可选) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 8603f121db..392801e0ae 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -53,8 +53,12 @@ services: - HTTPS_PROXY=${SUB2API_DEV_HTTPS_PROXY:-http://host.docker.internal:7897} - ALL_PROXY=${SUB2API_DEV_ALL_PROXY:-socks5://host.docker.internal:7897} - NO_PROXY=${SUB2API_DEV_NO_PROXY:-127.0.0.1,localhost,::1,postgres,redis,sub2api,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,.local} - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -63,8 +67,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 588862e2f2..0df1fb0d8b 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -159,10 +159,14 @@ services: - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -171,8 +175,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index cd0a008871..bca5e48514 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -106,10 +106,14 @@ services: - ANTIGRAVITY_USER_AGENT_VERSION=${ANTIGRAVITY_USER_AGENT_VERSION:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -118,8 +122,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 68a51e78dd..f15b40590d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -156,10 +156,14 @@ services: - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -168,8 +172,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/tests/docker-compose-gateway-env-test.sh b/deploy/tests/docker-compose-gateway-env-test.sh new file mode 100644 index 0000000000..f068410ba2 --- /dev/null +++ b/deploy/tests/docker-compose-gateway-env-test.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +gateway_variables=$(mktemp "${TMPDIR:-/tmp}/sub2api-gateway-env.XXXXXX") +cleanup() { + rm -f "$gateway_variables" +} +trap cleanup EXIT HUP INT TERM + +awk ' + /^GATEWAY_[A-Z0-9_]+=/ { + separator = index($0, "=") + print substr($0, 1, separator - 1) "\t" substr($0, separator + 1) + } +' deploy/.env.example > "$gateway_variables" + +for compose_file in \ + deploy/docker-compose.yml \ + deploy/docker-compose.local.yml \ + deploy/docker-compose.standalone.yml \ + deploy/docker-compose.dev.yml +do + tab=$(printf '\t') + while IFS="$tab" read -r key value; do + # .env.example intentionally includes high-capacity tuning examples for + # these values. An unconfigured Compose deployment must retain the + # backend defaults instead of silently adopting the examples. + case "$key" in + GATEWAY_MAX_CONNS_PER_HOST) value=1024 ;; + GATEWAY_MAX_IDLE_CONNS) value=2560 ;; + GATEWAY_MAX_IDLE_CONNS_PER_HOST) value=120 ;; + esac + + expected=$(printf ' - %s=${%s:-%s}' "$key" "$key" "$value") + expected_count=$(grep -Fxc "$expected" "$compose_file" || true) + key_count=$(grep -Ec "^[[:space:]]*-[[:space:]]*${key}([[:space:]]*=.*)?[[:space:]]*$" "$compose_file" || true) + if [ "$expected_count" -ne 1 ] || [ "$key_count" -ne 1 ]; then + printf '%s must pass %s with the expected fallback exactly once\n' "$compose_file" "$key" >&2 + exit 1 + fi + done < "$gateway_variables" +done + +printf 'docker compose Gateway environment test passed\n' diff --git a/docs/ADMIN_PAYMENT_INTEGRATION_API.md b/docs/ADMIN_PAYMENT_INTEGRATION_API.md index f674f86c93..6d3cd0122e 100644 --- a/docs/ADMIN_PAYMENT_INTEGRATION_API.md +++ b/docs/ADMIN_PAYMENT_INTEGRATION_API.md @@ -119,8 +119,8 @@ https://pay.example.com/pay?user_id=123&token=&theme=light&lang=zh&ui_mode= - 重试保持相同 `code`,并使用新的 `Idempotency-Key` ### 6) `doc_url` 配置建议 -- 查看链接:`https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md` -- 下载链接:`https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md` +- 查看链接:`https://github.com/Wei-Shaw/sub2api/blob/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md` +- 下载链接:`https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md` --- @@ -239,5 +239,5 @@ https://pay.example.com/pay?user_id=123&token=&theme=light&lang=zh&ui_mode= - Keep the same `code` for retry, and use a new `Idempotency-Key` ### 6) Recommended `doc_url` -- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md` -- Download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md` +- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md` +- Download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md` diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index c0e849e538..de6d0cf071 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -53,6 +53,12 @@ route's `upstream_model` before dispatch. For Gemini native paths such as `/v1beta/models/{model}:generateContent`, the gateway resolves `{model}` and the handler forwards the resolved upstream model. +Codex Alpha Search and Live requests use the `responses` route domain. Live +requests resolve the model from `session.model`, including multipart `session` +payloads, and apply the configured `upstream_model` before dispatch. +Codex model manifest requests reuse the existing OpenAI account selection and +failover path within the Composite group. + ## Built-In Detection Composite routing detects common public model IDs and provider-prefixed IDs: diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000000..1b7b248947 --- /dev/null +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,233 @@ +# Sub2API 插件开发教程 + +本文面向希望为 Sub2API 开发、打包和发布插件的团队。插件是独立进程和静态 UI 组成的 `.s2plugin` 包,宿主通过稳定的 gRPC 协议调用它。本文以当前宿主已经定义的 `openai.oauth.outbound_transport.v1` 能力作为协议示例,说明开发者需要准备什么、哪些职责属于插件、哪些职责仍由 Sub2API 负责。 + +本文不是一个可直接安装的完整插件,也不代表 Sub2API 已经发布对应的官方插件包。当前文档主要描述公开协议、宿主边界和开发流程。后续是否发布可安装包、支持哪些 Provider,以及如何提供示例仓库,都需要另行公告。 + +## 1. 准备开发环境 + +建议使用以下环境: + +- Go 1.21 或更高版本; +- Node.js(仅在插件 UI 使用 JavaScript 时需要); +- Git; +- 与目标部署环境一致的构建工具链。 + +协议定义和通用说明位于: + +- `backend/pkg/pluginapi/v1/plugin.proto`:进程间消息和流式请求定义; +- `backend/pkg/pluginapi/v1/runtime.go`:插件进程启动入口; +- `backend/pkg/pluginapi/v1/manifest.schema.json`:包清单 JSON Schema; +- `backend/pkg/pluginapi/docs/`:开发、UI Bridge、包格式和安全边界说明。 + +目前暂未提供可直接复制的官方示例源码。开发者可以按照本文的目录和协议说明创建自己的插件工程;示例仓库发布后,会在本文补充正式的获取地址、目录说明和版本要求。公开协议始终以 `backend/pkg/pluginapi/` 为准。 + +## 2. 创建插件工程 + +在示例仓库发布前,可以先创建一个独立的 Go 工程,目录建议如下: + +```text +my-plugin/ +├── cmd//main.go +├── internal/pluginconfig/ +├── internal/transport/ +├── ui/index.html +├── ui/assets/ +├── tools/ +├── manifest.source.json +└── build.sh +``` + +开发时至少准备以下部分: + +1. `manifest.source.json`:插件 ID、名称、版本、作者、能力和兼容的 Sub2API 版本; +2. `cmd//main.go`:启动入口和运行时版本注入,并同步打包器中的构建目标和二进制名称; +3. `internal/pluginconfig/`:配置结构、默认值、严格校验和规范化; +4. `internal/transport/`:HTTP 客户端、代理、请求头、请求体、网络连接参数、响应流和资源回收; +5. `ui/index.html` 与 `ui/assets/`:插件自己的配置界面; +6. 单元测试、进程集成测试和目标平台构建配置。 + +入口文件应保持很小,只负责调用 `pluginv1.Serve`。实际逻辑放在可独立测试的包中,避免把配置解析、网络请求和协议组装全部写在 `main.go`。 + +## 3. 编写运行时 + +运行时实现 `TransportPlugin` 服务,必须满足以下约定: + +| 方法 | 要求 | +| --- | --- | +| `GetInfo` | 返回的插件 ID、版本、协议版本、传输 API 版本和能力必须与清单一致。 | +| `Health` | 快速返回进程是否可以接收新请求,不执行长时间网络探测。 | +| `ValidateConfig` | 严格解析 JSON,拒绝未知字段和非法范围,并返回完整的规范化配置。 | +| `ApplyConfig` | 成功后原子切换配置;失败时保留旧配置和旧连接。 | +| `TestConfig` | 针对已保存配置进行快速诊断,返回简短、可展示的结果。 | +| `Forward` | 按协议接收请求流,发出上游请求,再按顺序返回响应流。 | + +请求帧顺序为 `start`、零到多个 `body_chunk`、`body_end`;响应帧顺序为 `start`、零到多个 `body_chunk`、`end`。不能继续处理时发送 `error` 帧。 + +`ForwardResponseError.request_sent` 必须准确:只有在能够确认尚未调用上游 HTTP Transport 时才返回 `false`;一旦已经调用,或无法确认上游是否收到请求,就返回 `true`。宿主会据此决定是否允许切换账号重试,避免重复执行同一个请求。 + +资源管理也属于运行时契约:复用 HTTP Transport 和连接池,配置切换时关闭旧空闲连接,沿用 gRPC stream 的 context 取消 DNS、连接、上传和响应读取,并始终关闭上游响应体。日志和错误消息不能包含 Token、代理凭据、完整请求体或敏感响应头。 + +## 4. 设计插件配置 + +插件配置由插件定义,由 Sub2API 加密保存。推荐流程是: + +1. 在 `internal/pluginconfig.Config` 中定义字段和默认值; +2. 使用 `json.Decoder.DisallowUnknownFields` 等严格方式解析; +3. 将空对象规范化为完整默认配置; +4. 在 `ValidateConfig` 和 `ApplyConfig` 中复用同一套校验; +5. 配置应用成功后再让宿主保存,保存失败时允许恢复旧配置。 + +JSON 字段统一使用 `snake_case`。敏感配置不要放入 URL、UI 通知、诊断结果或日志。插件不应从 UI 读取、刷新或持久化 OAuth Token;宿主只在运行时调用需要的网络转发接口。 + +## 5. 实现插件自己的配置 UI + +UI 是插件包内的静态页面,不需要修改 Sub2API 前端源码。宿主会在受限 iframe 中加载 `ui/index.html`,并通过 UI Bridge 提供配置读写和测试能力。 + +页面初始化流程: + +1. 加载包内 HTML、CSS 和 JavaScript; +2. 创建 Bridge 并注册 `message` 监听; +3. 发送 `sub2api.plugin.ready`; +4. 调用 `config.load` 渲染表单; +5. 编辑后调用 `config.save`; +6. 测试前先保存,再调用 `config.test`; +7. 页面卸载时调用 `dispose()`。 + +当前 Bridge 支持: + +| 消息 | 用途 | +| --- | --- | +| `config.load` | 读取当前配置。 | +| `config.save` | 提交配置,由运行时校验、应用并加密保存。 | +| `config.test` | 运行已保存配置的诊断。 | +| `ui.resize` | 调整配置 iframe 高度。 | +| `ui.notify` | 显示成功、错误或提示消息。 | + +每条消息都必须带 `request_id`,并校验 `event.source`、消息来源标识和 Bridge Token。不要依赖 CDN、远程脚本、Cookie 或本地存储。页面需要兼容窄屏和明暗主题,并正确处理加载、保存、测试、超时和未保存状态。 + +详细信封格式见 `backend/pkg/pluginapi/docs/ui-bridge.md`。如果后续示例仓库提供可复用的 Bridge SDK,本文会在示例仓库章节补充对应路径和使用方式。 + +## 6. 编写包清单 + +只维护 `manifest.source.json`,不要手工编辑构建目录中的 `manifest.json`。至少需要填写: + +```json +{ + "schema_version": 1, + "id": "example.openai.transport", + "name": "Example OpenAI Transport", + "version": "0.1.0", + "requires": { + "sub2api": ">=0.1.179 <0.2.0", + "recommended_sub2api_version": "0.1.179", + "tested_sub2api_versions": ["0.1.179"], + "plugin_protocol": 1, + "transport_api": 1, + "ui_bridge": 1 + }, + "capabilities": [ + { + "id": "openai.oauth.outbound_transport.v1", + "platform": "openai", + "account_type": "oauth" + } + ], + "runtimes": {}, + "ui": { "entrypoint": "ui/index.html" }, + "files": {} +} +``` + +打包器会自动填充目标平台运行时、UI 和运行时文件的 SHA-256。清单中的 `requires.sub2api` 是硬兼容范围;`tested_sub2api_versions` 应只填写真实验证过的版本;`recommended_sub2api_version` 用于管理页面展示。当前宿主仅处理 `openai.oauth.outbound_transport.v1`,声明其他能力不会自动产生新路由。后续增加 Provider 支持时,会在协议、能力清单和宿主路由完成适配后,再补充对应的清单示例。 + +## 7. 生成密钥并签名 + +生产包应始终签名,宿主默认拒绝未签名包。可以使用插件工程中的密钥生成工具生成一对 Ed25519 密钥;示例仓库发布后会提供标准工具和完整命令: + +```bash +go run ./tools/keygen -out build/keys/my-publisher +``` + +生成的 `my-publisher.private` 只保存在受控的开发机或 CI Secret 中,不能提交到源码仓库、插件包或部署服务器。公钥是 Base64 文本,可以提供给部署者。 + +插件工程的 `build.sh` 应调用标准打包器。自定义发布者密钥时必须同时提供 `-signing-key` 和 `-key-id`: + +```bash +./build.sh \ + -signing-key /安全目录/my-publisher.private \ + -key-id my-publisher-v1 \ + -output dist/my-openai-plugin.s2plugin +``` + +签名覆盖最终 `manifest.json` 的精确字节;清单中的文件哈希再覆盖运行时和 UI 文件。签名完成后不要重新格式化 `manifest.json`。 + +部署者在 Sub2API 配置文件中追加公钥: + +```yaml +plugins: + allow_unsigned: false + trusted_publishers: + my-publisher-v1: "BASE64_ED25519_PUBLIC_KEY" +``` + +`trusted_publishers` 是在宿主内置官方公钥之外追加的信任来源,不能覆盖内置公钥。`signature.json` 中的 `key_id` 必须与配置键完全一致。密钥轮换时先发布包含新公钥的宿主配置或版本,再发布新签名包,最后再停用旧密钥。 + +开发阶段如需使用未签名包,只应在隔离的本地环境临时设置 `plugins.allow_unsigned: true`,测试完成后立即恢复为 `false`。 + +## 8. 构建、测试和安装 + +在插件目录执行: + +```bash +go test ./... -count=1 +node --check ui/assets/bridge-v1.js +node --check ui/assets/app.js +./build.sh +unzip -t dist/*.s2plugin +``` + +回到 Sub2API 仓库根目录后,再使用真实构建包运行宿主集成测试: + +```bash +cd ../.. +SUB2API_TEST_PLUGIN_PACKAGE=plugins/my-openai-plugin/dist/my-openai-plugin.s2plugin \ + go test ./backend/internal/service -run '^TestPluginRuntimeIntegration$' -count=1 +``` + +最低测试集应覆盖配置默认值和边界值、插件身份、请求和响应分块、流式响应、上下文取消、插件退出、代理开关、包哈希、签名、路径安全、目标平台运行时以及 UI Bridge 的加载、保存、测试、错误和超时。 + +安装后先保持停用,确认清单兼容性、签名和诊断结果,再按账号灰度启用。API Key 账号和未命中灰度的 OAuth 账号继续走 Sub2API 原有路径。 + +## 9. 发布前检查清单 + +- 插件版本与 `GetInfo` 返回值一致; +- `requires.sub2api` 覆盖范围经过验证,没有未经测试的破坏性版本; +- `tested_sub2api_versions` 与实际测试记录一致; +- 每个支持的平台和架构都有运行时文件; +- 生产包存在有效 `signature.json`,公钥已交付部署者; +- 包中没有私钥、源映射、测试数据、日志和临时文件; +- UI 不依赖外部资源,也不保存宿主会话信息; +- 配置切换、请求取消、响应关闭和错误重试语义经过测试; +- 发布说明包含升级、停用、回滚和兼容版本信息。 + +## 10. 常见问题 + +| 现象 | 排查方向 | +| --- | --- | +| 安装提示签名不受信任 | 检查 `signature.json.key_id`、Base64 公钥和配置键是否完全一致。 | +| 插件显示不兼容 | 检查 `requires.sub2api`、`plugin_protocol`、`transport_api` 和 `ui_bridge`。 | +| 插件进程无法启动 | 检查目标系统和架构对应的运行时路径、可执行权限和运行用户权限。 | +| 配置页无法加载 | 检查 `ui.entrypoint`、UI 文件哈希、Bridge Token 校验和 iframe 消息来源。 | +| 保存后配置未生效 | 查看 `ValidateConfig`、`ApplyConfig` 返回的规范化配置和诊断信息。 | +| 请求失败后重复执行 | 检查 `ForwardResponseError.request_sent` 是否准确反映请求是否可能已发出。 | + +## 11. 需要扩展能力时 + +如果新插件需要支持其他 Provider、其他账号类型或新的消息字段,应先扩展并版本化公开协议,再由宿主增加能力匹配和生命周期处理。不要仅通过清单声明一个宿主尚未实现的能力。这样可以让旧插件继续运行,也能让新宿主明确拒绝不兼容的插件。 + +Sub2API 后续会持续补充更多 Provider 的插件适配说明,包括能力标识、请求和响应契约、配置字段、UI Bridge 使用方式、版本兼容要求以及测试清单。本文会随着这些能力的落地继续更新,Provider 专属章节会放在本节之后。 + +## 12. 示例仓库预留 + +后续计划提供独立的插件示例仓库,用于存放可复用的运行时骨架、UI 组件、打包工具和各 Provider 的最小实现。目前示例仓库尚未准备完成,因此暂不提供地址;正式发布后会在这里补充仓库地址、适用的 Sub2API 版本、示例插件版本和构建说明。 diff --git a/docs/screenshots/mobile-account-actions-menu.png b/docs/screenshots/mobile-account-actions-menu.png deleted file mode 100644 index f09684a612..0000000000 Binary files a/docs/screenshots/mobile-account-actions-menu.png and /dev/null differ diff --git a/frontend/package.json b/frontend/package.json index 1d4aaf3aef..8af7a4668b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "@vueuse/core": "^10.7.0", "axios": "^1.18.0", "chart.js": "^4.4.1", - "dompurify": "^3.3.1", + "dompurify": "^3.4.14", "driver.js": "^1.4.0", "file-saver": "^2.0.5", "marked": "^17.0.1", @@ -63,7 +63,8 @@ "overrides": { "js-cookie": "3.0.7", "form-data@<4.0.6": ">=4.0.6", - "postcss@<8.5.18": ">=8.5.18" + "postcss@<8.5.18": ">=8.5.18", + "dompurify@<3.4.14": ">=3.4.14" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e17969c9cc..fef7a9441b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: js-cookie: 3.0.7 form-data@<4.0.6: '>=4.0.6' postcss@<8.5.18: '>=8.5.18' + dompurify@<3.4.14: '>=3.4.14' importers: @@ -35,8 +36,8 @@ importers: specifier: ^4.4.1 version: 4.5.1 dompurify: - specifier: ^3.3.1 - version: 3.3.1 + specifier: ^3.4.14 + version: 3.4.14 driver.js: specifier: ^1.4.0 version: 1.4.0 @@ -163,12 +164,6 @@ packages: react: '>=18' react-dom: '>=18' - '@ant-design/cssinjs@2.0.1': - resolution: {integrity: sha512-Lw1Z4cUQxdMmTNir67gU0HCpTl5TtkKCJPZ6UBvCqzcOTl/QmMFB6qAEoj8qFl0CuZDX9qQYa3m9+rEKfaBSbA==} - peerDependencies: - react: '>=16.0.0' - react-dom: '>=16.0.0' - '@ant-design/cssinjs@2.1.2': resolution: {integrity: sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==} peerDependencies: @@ -1249,12 +1244,6 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' - '@rc-component/util@1.7.0': - resolution: {integrity: sha512-tIvIGj4Vl6fsZFvWSkYw9sAfiCKUXMyhVz6kpKyZbwyZyRPqv2vxYZROdaO1VB4gqTNvUZFXh6i3APUiterw5g==} - peerDependencies: - react: '>=18.0.0' - react-dom: '>=18.0.0' - '@rc-component/virtual-list@1.0.2': resolution: {integrity: sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==} engines: {node: '>=8.x'} @@ -1296,67 +1285,56 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -1644,6 +1622,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -2322,11 +2301,8 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} - dompurify@3.3.1: - resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} - - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} driver.js@1.4.0: resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} @@ -2641,11 +2617,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -3322,8 +3299,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4398,6 +4375,7 @@ packages: vue-i18n@9.14.5: resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} engines: {node: '>= 16'} + deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html peerDependencies: vue: ^3.0.0 @@ -4570,18 +4548,6 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@ant-design/cssinjs@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@babel/runtime': 7.28.4 - '@emotion/hash': 0.8.0 - '@emotion/unitless': 0.7.5 - '@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - clsx: 2.1.1 - csstype: 3.2.3 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - stylis: 4.3.6 - '@ant-design/cssinjs@2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.2 @@ -4792,7 +4758,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -4835,7 +4801,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -5780,13 +5746,6 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-is: 18.3.1 - '@rc-component/util@1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - is-mobile: 5.0.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-is: 18.3.1 - '@rc-component/virtual-list@1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.2 @@ -6041,7 +6000,7 @@ snapshots: '@types/dompurify@3.2.0': dependencies: - dompurify: 3.3.1 + dompurify: 3.4.14 '@types/estree-jsx@1.0.5': dependencies: @@ -6421,8 +6380,8 @@ snapshots: antd-style@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@ant-design/cssinjs': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@babel/runtime': 7.28.4 + '@ant-design/cssinjs': 2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@babel/runtime': 7.29.2 '@emotion/cache': 11.14.0 '@emotion/css': 11.13.5 '@emotion/react': 11.14.0(@types/react@19.2.7)(react@19.2.3) @@ -6537,7 +6496,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 resolve: 1.22.11 @@ -6975,11 +6934,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.3.1: - optionalDependencies: - '@types/trusted-types': 2.0.7 - - dompurify@3.3.3: + dompurify@3.4.14: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -8078,7 +8033,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.3.3 + dompurify: 3.4.14 katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.1 @@ -8443,7 +8398,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.17: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -8641,7 +8596,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/frontend/src/api/__tests__/admin.groups.usage-summary.spec.ts b/frontend/src/api/__tests__/admin.groups.usage-summary.spec.ts new file mode 100644 index 0000000000..c0b2c4bffc --- /dev/null +++ b/frontend/src/api/__tests__/admin.groups.usage-summary.spec.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get } = vi.hoisted(() => ({ + get: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + apiClient: { get }, +})) + +import { getUsageSummary } from '@/api/admin/groups' + +describe('admin group usage summary API', () => { + beforeEach(() => { + get.mockReset() + get.mockResolvedValue({ data: [] }) + }) + + it('does not send browser timezone parameters', async () => { + const summary = [ + { group_id: 1, today_cost: 1.25, yesterday_cost: 2.5, total_cost: 9.75 }, + ] + get.mockResolvedValue({ data: summary }) + + await expect(getUsageSummary()).resolves.toEqual(summary) + + expect(get).toHaveBeenCalledWith('/admin/groups/usage-summary') + }) +}) diff --git a/frontend/src/api/__tests__/codex.spec.ts b/frontend/src/api/__tests__/codex.spec.ts new file mode 100644 index 0000000000..57b19d7557 --- /dev/null +++ b/frontend/src/api/__tests__/codex.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildCodexModelsManifestUrl, + fetchCodexModelsManifest +} from '../codex' + +describe('Codex models API', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('builds the authenticated Codex manifest endpoint from the public API base', () => { + expect(buildCodexModelsManifestUrl('https://example.com/api/v1/')).toBe( + 'https://example.com/api/v1/models?client_version=0.147.0' + ) + }) + + it('fetches a manifest with the current API key without adding it to the catalog', async () => { + const manifest = { + models: [ + { + slug: 'grok-4.6', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses' }, + { effort: 'xhigh', description: 'Extra-high reasoning depth' } + ], + input_modalities: ['text', 'image'], + model_messages: { instructions_template: 'Use the routed model.' } + }, + { + slug: 'deepseek-v4-pro', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses' }, + { effort: 'max', description: 'Maximum reasoning depth' } + ], + input_modalities: ['text'], + model_messages: { instructions_template: 'Use the routed model.' } + } + ] + } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => manifest + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/v1/models?client_version=0.147.0', + expect.objectContaining({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer sk-user-test' + } + }) + ) + expect(result.modelCount).toBe(2) + expect(JSON.parse(result.content)).toEqual(manifest) + expect(result.content).toContain('"effort": "xhigh"') + expect(result.content).toContain('"input_modalities"') + expect(result.content).toContain('"instructions_template"') + expect(result.content).not.toContain('sk-user-test') + }) + + it('rejects a successful response that is not a Codex manifest', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ object: 'list', data: [] }) + })) + + await expect(fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test')) + .rejects.toThrow('valid manifest') + }) +}) diff --git a/frontend/src/api/__tests__/tokenRefresh.spec.ts b/frontend/src/api/__tests__/tokenRefresh.spec.ts index a7fee8c643..80b8c8a363 100644 --- a/frontend/src/api/__tests__/tokenRefresh.spec.ts +++ b/frontend/src/api/__tests__/tokenRefresh.spec.ts @@ -91,6 +91,23 @@ describe('refreshAuthTokens', () => { }) }) + it('does not mistake boundary timer jitter for a completed peer refresh', async () => { + vi.useFakeTimers() + seedSession({ token_expires_at: String(Date.now() + 120_001) }) + mockedPost.mockResolvedValueOnce(refreshedResponse()) + const request = vi.fn(async (_name: string, callback: () => Promise) => callback()) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request } + }) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + await expect(refreshAuthTokens()).resolves.toMatchObject({ access_token: 'new-access' }) + + expect(request).toHaveBeenCalledTimes(1) + expect(mockedPost).toHaveBeenCalledTimes(1) + }) + it('recovers when a peer publishes the rotated token just after this request fails', async () => { seedSession() mockedPost.mockRejectedValueOnce(new Error('refresh token already used')) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 21ce9f092f..ee053ad9ec 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -470,6 +470,7 @@ export async function bulkUpdate( failed: number success_ids?: number[] failed_ids?: number[] + long_context_inherited_count?: number results: Array<{ account_id: number; success: boolean; error?: string }> }> { const payload = Array.isArray(accountIdsOrPayload) @@ -483,6 +484,7 @@ export async function bulkUpdate( failed: number success_ids?: number[] failed_ids?: number[] + long_context_inherited_count?: number results: Array<{ account_id: number; success: boolean; error?: string }> }>('/admin/accounts/bulk-update', payload) return data @@ -539,6 +541,25 @@ export async function getAvailableModels(id: number): Promise { export interface SyncUpstreamModelsResult { models: string[] + metadata?: Record + warnings?: UpstreamModelSyncWarning[] +} + +export interface UpstreamModelSyncWarning { + code: string + message: string +} + +export interface UpstreamModelMetadata { + id: string + display_name?: string + description?: string + reasoning?: boolean + default_reasoning_level?: string + supported_reasoning_levels?: string[] + input_modalities?: string[] + context_window?: number + max_output_tokens?: number } /** @@ -556,6 +577,7 @@ export interface SyncUpstreamPreviewParams { type: string base_url?: string api_key: string + model_mapping?: Record } /** diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index 5eb78b798f..bf9e1c88fb 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -5,10 +5,57 @@ import { apiClient } from '../client' -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok' +export type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'grok' + | 'antigravity' + | 'kimi' + | 'zhipu' + | 'deepseek' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' export type APIMode = 'chat_completions' | 'responses' +/** + * probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本); + * quota_probe = 探活 + 配额快照挂主模型行。 + */ +export type CheckMode = 'probe' | 'quota' | 'quota_probe' + +/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */ +export interface MonitorQuotaTier { + /** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */ + window: string + /** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */ + label?: string + used_percent: number + used?: number + limit?: number + /** RFC3339;空表示无重置时间 */ + reset_at?: string +} + +export interface MonitorBalance { + currency: string + balance: number +} + +/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */ +export interface MonitorQuotaSnapshot { + /** usage | cn_quota | cn_balance */ + source: string + success: boolean + tiers?: MonitorQuotaTier[] + balance?: number | null + balances?: MonitorBalance[] + currency?: string + plan_level?: string + /** 401/403 鉴权失败标记(推导为 failed 状态) */ + credential_invalid?: boolean + error?: string + fetched_at: string +} export interface ChannelMonitor { id: number @@ -47,6 +94,12 @@ export interface ChannelMonitor { extra_headers: Record body_override_mode: BodyOverrideMode body_override: Record | null + /** 检测模式:probe(默认)/ quota / quota_probe */ + check_mode: CheckMode + /** 配额模式关联的账号 ID;探活模式为 null */ + account_id: number | null + /** 主模型最近一次配额快照(配额模式;无历史时为 null) */ + latest_quota?: MonitorQuotaSnapshot | null } export interface ExtraModelStatus { @@ -75,8 +128,16 @@ export interface CreateParams { name: string provider: Provider api_mode?: APIMode + /** 探活模式必填(base origin);quota 模式可留空 */ endpoint: string + /** 探活模式必填;quota 模式可留空 */ api_key: string + /** 缺省 probe;antigravity 仅支持 quota */ + check_mode?: CheckMode + /** 配额模式必填:数据源账号(provider 需与账号平台一致)。 + * update 语义:>0=换绑,0=解绑(切回 probe 模式时前端发 0 清空存量关联); + * create 绝不发 0——后端会把 0 存成 &0 触发外键违约。 */ + account_id?: number | null primary_model: string extra_models?: string[] group_name?: string @@ -89,7 +150,8 @@ export interface CreateParams { body_override?: Record | null } -// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空 +// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空; +// account_id=0 显式解绑关联账号(null = 不动,见 CreateParams 注释) export type UpdateParams = Partial & { clear_template?: boolean } @@ -101,6 +163,8 @@ export interface CheckResult { ping_latency_ms: number | null message: string checked_at: string + /** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */ + quota?: MonitorQuotaSnapshot | null } export interface RunNowResponse { @@ -115,6 +179,8 @@ export interface HistoryItem { ping_latency_ms: number | null message: string checked_at: string + /** 配额快照(配额模式行;探活行为空) */ + quota?: MonitorQuotaSnapshot | null } export interface HistoryParams { diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index fdbeadf57a..94967e3692 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -17,10 +17,26 @@ export interface PricingInterval { output_price: number | null cache_write_price: number | null cache_read_price: number | null + input_multiplier: number | null + output_multiplier: number | null + cache_write_multiplier: number | null + cache_read_multiplier: number | null per_request_price: number | null sort_order: number } +export interface ChannelTimePricingPeriod { + start_time: string + end_time: string + multiplier: number +} + +export interface ChannelTimePricing { + timezone: string + weekdays_only?: boolean + periods: ChannelTimePricingPeriod[] +} + export interface ChannelModelPricing { id?: number platform: string @@ -30,10 +46,13 @@ export interface ChannelModelPricing { output_price: number | null cache_write_price: number | null cache_read_price: number | null + fast_multiplier?: number | null + flex_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null intervals: PricingInterval[] + time_pricing: ChannelTimePricing | null } export interface AccountStatsPricingRule { diff --git a/frontend/src/api/admin/cnProviders.ts b/frontend/src/api/admin/cnProviders.ts new file mode 100644 index 0000000000..668f67a73e --- /dev/null +++ b/frontend/src/api/admin/cnProviders.ts @@ -0,0 +1,70 @@ +/** + * Admin CN providers (Kimi / Zhipu / DeepSeek) API endpoints. + * Coding-plan rolling-window quota probe + payg balance probe. + */ + +import { apiClient } from '../client' + +/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */ +export interface CNQuotaTier { + window: '5h' | 'weekly' + used_percent: number + reset_at?: string +} + +/** Coding Plan 额度探测结果(kimi / zhipu),对齐后端 CNProviderQuotaProbeResult。 */ +export interface CNProviderQuotaProbeResult { + provider: string + source?: string + success: boolean + credential_valid: boolean + tiers?: CNQuotaTier[] + plan_level?: string + status_code?: number + fetched_at: number + persisted: boolean + error?: string +} + +/** 单币种余额明细(deepseek 双币种账号含 CNY + USD 两条)。 */ +export interface CNProviderBalanceEntry { + currency: string + balance: number +} + +/** payg 余额探测结果(kimi / deepseek),对齐后端 CNProviderBalanceResult。 */ +export interface CNProviderBalanceResult { + provider: string + success: boolean + /** 主币种余额(balances 首条,兼容单币种展示)。 */ + balance: number + currency?: string + /** 多币种明细;缺省时按主币种展示。 */ + balances?: CNProviderBalanceEntry[] + available: boolean + status_code?: number + fetched_at: number + persisted: boolean + error?: string +} + +/** 查询 Coding Plan 滚动窗口用量(5h + weekly)。 */ +export async function queryQuota(id: number): Promise { + const { data } = await apiClient.get( + `/admin/cn-providers/accounts/${id}/quota` + ) + return data +} + +/** 查询 payg 账号余额。 */ +export async function queryBalance(id: number): Promise { + const { data } = await apiClient.get( + `/admin/cn-providers/accounts/${id}/balance` + ) + return data +} + +export default { + queryQuota, + queryBalance +} diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 3828bf9d71..2c9c80f5e6 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -446,18 +446,15 @@ export async function clearGroupRPMOverrides(id: number): Promise<{ message: str } /** - * Get usage summary (today + cumulative cost) for all groups - * @param timezone - IANA timezone string (e.g. "Asia/Shanghai") + * Get usage summary (today + yesterday + cumulative cost) for all groups * @returns Array of group usage summaries */ -export async function getUsageSummary( - timezone?: string -): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> { +export async function getUsageSummary(): Promise< + { group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[] +> { const { data } = await apiClient.get< - { group_id: number; today_cost: number; total_cost: number }[] - >('/admin/groups/usage-summary', { - params: timezone ? { timezone } : undefined - }) + { group_id: number; today_cost: number; yesterday_cost: number; total_cost: number }[] + >('/admin/groups/usage-summary') return data } diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 80a7073dec..0616449f1b 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -18,6 +18,7 @@ import usageAPI from './usage' import geminiAPI from './gemini' import antigravityAPI from './antigravity' import grokAPI from './grok' +import cnProvidersAPI from './cnProviders' import userAttributesAPI from './userAttributes' import opsAPI from './ops' import errorPassthroughAPI from './errorPassthrough' @@ -34,6 +35,7 @@ import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' import adminComplianceAPI from './compliance' import auditAPI from './audit' +import pluginsAPI from './plugins' /** * Unified admin API object for convenient access @@ -54,6 +56,7 @@ export const adminAPI = { gemini: geminiAPI, antigravity: antigravityAPI, grok: grokAPI, + cnProviders: cnProvidersAPI, userAttributes: userAttributesAPI, ops: opsAPI, errorPassthrough: errorPassthroughAPI, @@ -69,7 +72,8 @@ export const adminAPI = { affiliates: affiliatesAPI, riskControl: riskControlAPI, compliance: adminComplianceAPI, - audit: auditAPI + audit: auditAPI, + plugins: pluginsAPI } export { @@ -88,6 +92,7 @@ export { geminiAPI, antigravityAPI, grokAPI, + cnProvidersAPI, userAttributesAPI, opsAPI, errorPassthroughAPI, @@ -103,7 +108,8 @@ export { affiliatesAPI, riskControlAPI, adminComplianceAPI, - auditAPI + auditAPI, + pluginsAPI } export default adminAPI @@ -115,3 +121,9 @@ export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from export type { BackupAgentHealth, DataManagementConfig } from './dataManagement' export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile' export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl' +export type { + PluginInstallation, + PluginCompatibility, + PluginUISession, + PluginTestResult +} from './plugins' diff --git a/frontend/src/api/admin/plugins.ts b/frontend/src/api/admin/plugins.ts new file mode 100644 index 0000000000..8b4489d41c --- /dev/null +++ b/frontend/src/api/admin/plugins.ts @@ -0,0 +1,156 @@ +import { apiClient } from '../client' + +export interface PluginCapability { + id: string + platform: string + account_type: string +} + +export interface PluginRequirements { + sub2api: string + recommended_sub2api_version?: string + tested_sub2api_versions?: string[] + plugin_protocol: number + transport_api: number + ui_bridge: number +} + +export interface PluginManifest { + schema_version: number + id: string + name: string + version: string + description?: string + author?: string + requires: PluginRequirements + capabilities: PluginCapability[] + ui: { entrypoint: string } +} + +export interface PluginCompatibility { + compatible: boolean + tested: boolean + status: 'compatible' | 'untested' | 'incompatible' + message: string + current_sub2api_version: string + required_sub2api_version: string + recommended_sub2api_version: string + plugin_protocol: number + transport_api: number + ui_bridge: number +} + +export interface PluginBinding { + id: number + plugin_id: number + capability: string + platform: string + account_type: string + enabled: boolean + rollout_percent: number +} + +export interface PluginInstallation { + id: number + plugin_key: string + name: string + version: string + description: string + author: string + manifest: PluginManifest + binary_sha256: string + signature_status: 'trusted' | 'unsigned' + state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible' + last_error: string + installed_at: string + enabled_at?: string + updated_at: string + bindings: PluginBinding[] + compatibility: PluginCompatibility + runtime_healthy: boolean + runtime_message: string +} + +export interface PluginTestResult { + success: boolean + message: string + latency_ms: number +} + +export interface PluginUISession { + url: string + bridge_token: string + ui_bridge_version: number + expires_at: string +} + +export async function list(): Promise { + const { data } = await apiClient.get('/admin/plugins') + return data +} + +export async function upload(file: File): Promise { + const form = new FormData() + form.append('plugin', file) + const { data } = await apiClient.post('/admin/plugins/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000 + }) + return data +} + +export async function enable( + id: number, + rolloutPercent: number, + acceptUntested: boolean +): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/enable`, { + rollout_percent: rolloutPercent, + accept_untested: acceptUntested + }) + return data +} + +export async function disable(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/disable`) + return data +} + +export async function remove(id: number): Promise { + await apiClient.delete(`/admin/plugins/${id}`) +} + +export async function getConfig(id: number): Promise> { + const { data } = await apiClient.get>(`/admin/plugins/${id}/config`) + return data +} + +export async function saveConfig( + id: number, + config: Record +): Promise> { + const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config) + return data +} + +export async function test(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/test`) + return data +} + +export async function createUISession(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/ui-session`) + return data +} + +export default { + list, + upload, + enable, + disable, + remove, + getConfig, + saveConfig, + test, + createUISession +} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b176024ae6..4ceb75cbd9 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -36,13 +36,19 @@ export type SchedulingThresholdPlatformType = | "openai" | "anthropic" | "grok" + | "kimi" + | "zhipu" export type AccountSchedulingThresholdsMap = Record +// 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型, +// 走余额检测而非用量阈值)。 export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ "openai", "anthropic", "grok", + "kimi", + "zhipu", ] export function normalizeAccountSchedulingThresholdsMap( @@ -712,6 +718,7 @@ export interface SystemSettings { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -720,6 +727,7 @@ export interface SystemSettings { model_plaza_enabled: boolean; model_plaza_require_auth: boolean; model_plaza_description: string; + plugin_management_enabled: boolean; // Affiliate (邀请返利) feature switch affiliate_enabled: boolean; @@ -1010,6 +1018,7 @@ export interface UpdateSettingsRequest { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds?: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; @@ -1018,6 +1027,7 @@ export interface UpdateSettingsRequest { model_plaza_enabled?: boolean; model_plaza_require_auth?: boolean; model_plaza_description?: string; + plugin_management_enabled?: boolean; // Affiliate (邀请返利) feature switch affiliate_enabled?: boolean; diff --git a/frontend/src/api/channelMonitor.ts b/frontend/src/api/channelMonitor.ts index 38dd0c99a8..68076db77d 100644 --- a/frontend/src/api/channelMonitor.ts +++ b/frontend/src/api/channelMonitor.ts @@ -4,7 +4,7 @@ */ import { apiClient } from './client' -import type { Provider, MonitorStatus } from './admin/channelMonitor' +import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor' export type { Provider, MonitorStatus } from './admin/channelMonitor' @@ -33,6 +33,11 @@ export interface UserMonitorView { availability_7d: number extra_models: UserMonitorExtraModel[] timeline: MonitorTimelinePoint[] + /** + * 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时 + * 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。 + */ + latest_quota?: MonitorQuotaSnapshot | null } export interface UserMonitorListResponse { diff --git a/frontend/src/api/codex.ts b/frontend/src/api/codex.ts new file mode 100644 index 0000000000..948fa1eee9 --- /dev/null +++ b/frontend/src/api/codex.ts @@ -0,0 +1,56 @@ +export interface CodexModelsManifestResult { + content: string + modelCount: number +} + +const DEFAULT_CODEX_CLIENT_VERSION = '0.147.0' + +function normalizeCodexBaseUrl(baseUrl: string): string { + const fallback = typeof window !== 'undefined' ? window.location.origin : '' + const value = (baseUrl || fallback).trim().replace(/\/+$/, '') + if (!value) return '/v1' + return /\/v1$/i.test(value) ? value : `${value}/v1` +} + +export function buildCodexModelsManifestUrl( + baseUrl: string, + clientVersion = DEFAULT_CODEX_CLIENT_VERSION +): string { + const url = normalizeCodexBaseUrl(baseUrl) + const params = new URLSearchParams({ client_version: clientVersion }) + return `${url}/models?${params.toString()}` +} + +function isCodexModelsManifest(value: unknown): value is { models: unknown[] } { + return typeof value === 'object' && value !== null && Array.isArray((value as { models?: unknown }).models) +} + +export async function fetchCodexModelsManifest( + baseUrl: string, + apiKey: string, + signal?: AbortSignal +): Promise { + const response = await fetch(buildCodexModelsManifestUrl(baseUrl), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}` + }, + cache: 'no-store', + signal + }) + + if (!response.ok) { + throw new Error(`Codex models request failed with status ${response.status}`) + } + + const payload: unknown = await response.json() + if (!isCodexModelsManifest(payload)) { + throw new Error('Codex models response is not a valid manifest') + } + + return { + content: JSON.stringify(payload, null, 2), + modelCount: payload.models.length + } +} diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index c17b3aa1a7..e96a621934 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -5,9 +5,9 @@ */ import { apiClient } from './client' -import type { UserSupportedModelPricing } from './channels' +import type { UserPricingInterval, UserSupportedModelPricing } from './channels' -/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */ +/** 官方参考价(USD per token,与计费目录同源;字段缺失 = 目录未覆盖)。 */ export interface PlazaOfficialPricing { input_price: number | null output_price: number | null @@ -16,13 +16,43 @@ export interface PlazaOfficialPricing { /** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */ cache_write_1h_price?: number | null cache_read_price: number | null + /** 官方长上下文阶梯(多档模型才有),不受分组开关影响。 */ + intervals?: UserPricingInterval[] +} + +/** + * 多档时的计价基准: + * - whole_request:整单按所在档单价计价(目录阶梯、渠道区间); + * - marginal:仅超出阈值的部分按该档单价计价(平台旧规则)。 + */ +export type PlazaLongContextBasis = 'whole_request' | 'marginal' + +/** 分时倍率时段:配置时区当天 [start_time, end_time) 内整单实付乘 multiplier。 */ +export interface PlazaTimePricingPeriod { + start_time: string + end_time: string + multiplier: number +} + +/** 计费会生效的分时倍率(仅倍率 ≠ 1 的时段,已按开始时间升序)。 */ +export interface PlazaTimePricing { + /** IANA 时区名,如 Asia/Shanghai。 */ + timezone: string + /** true 时时段仅周一至周五生效,周末整天按标准价计费。 */ + weekdays_only?: boolean + periods: PlazaTimePricingPeriod[] } export interface PlazaModel { name: string platform: string + /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null + /** 仅多档模型返回。 */ + long_context_basis?: PlazaLongContextBasis + /** 仅配置了分时倍率的模型返回。 */ + time_pricing?: PlazaTimePricing } export interface ModelPlazaGroup { @@ -43,6 +73,8 @@ export interface ModelPlazaGroup { /** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */ image_rate_independent: boolean image_rate_multiplier: number + /** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */ + long_context_pricing_enabled: boolean models: PlazaModel[] } diff --git a/frontend/src/api/tokenRefresh.ts b/frontend/src/api/tokenRefresh.ts index 142dbba044..95747627bf 100644 --- a/frontend/src/api/tokenRefresh.ts +++ b/frontend/src/api/tokenRefresh.ts @@ -8,7 +8,6 @@ const REFRESH_TOKEN_KEY = 'refresh_token' const TOKEN_EXPIRES_AT_KEY = 'token_expires_at' const TOKEN_REFRESH_LOCK_NAME = 'sub2api-auth-token-refresh' const TOKEN_REFRESH_TIMEOUT_MS = 30_000 -const TOKEN_REFRESH_BUFFER_MS = 120_000 const PEER_REFRESH_WAIT_MS = 1_000 const PEER_REFRESH_GRACE_MS = 1_000 const PEER_REFRESH_POLL_MS = 25 @@ -106,17 +105,6 @@ function readPeerRefreshResult( return storedPair } - if (!failedAccessToken) { - const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)) - if ( - expiresAt === snapshot.expiresAt && - storedPair.access_token === snapshot.accessToken && - expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS - ) { - return storedPair - } - } - return null } diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 485b4b8701..966b0a5e08 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -365,6 +365,7 @@ label="24h" :title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })" :utilization="grokFreeTokenBar.utilization" + :window-stats="grokFreeQuotaUsage" :show-now-when-idle="true" color="emerald" /> @@ -378,6 +379,7 @@ label="7d" :utilization="grokWeeklyBillingBar.utilization" :resets-at="grokWeeklyBillingBar.resetsAt" + :window-stats="grokWeeklyBillingBar.windowStats" :show-now-when-idle="true" color="indigo" /> @@ -386,6 +388,7 @@ label="30d" :utilization="grokMonthlyBillingBar.utilization" :resets-at="grokMonthlyBillingBar.resetsAt" + :window-stats="grokMonthlyBillingBar.windowStats" :show-now-when-idle="true" color="indigo" /> @@ -394,12 +397,16 @@ class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400" > {{ t('admin.accounts.usageWindow.grokPrepaid') }} ${{ grokPrepaidMoneyLine.prepaid }} - + {{ t('admin.accounts.usageWindow.grokUsed') }} {{ grokPrepaidMoneyLine.used }}/{{ grokPrepaidMoneyLine.limit }} @@ -422,6 +429,21 @@ + + +